cagataydev commited on
Commit
1281486
·
verified ·
1 Parent(s): 095520b

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +572 -1
app.py CHANGED
@@ -57,6 +57,569 @@ MOBILE_CSS = """
57
  /* Status indicators */
58
  .status-online { color: #4CAF50; font-weight: bold; }
59
  .status-offline { color: #F44336; font-weight: bold; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  """
61
 
62
 
@@ -709,6 +1272,7 @@ with gr.Blocks(
709
  title="Neon VLA",
710
  theme=gr.themes.Soft(),
711
  css=MOBILE_CSS,
 
712
  ) as demo:
713
 
714
  # Header — compact for phone
@@ -761,7 +1325,14 @@ with gr.Blocks(
761
  gr.HTML(get_robot_control_html())
762
 
763
  # ═══════════════════════════════════════════════════════════
764
- # TAB 3: TRAINsimplified training interface
 
 
 
 
 
 
 
765
  # ═══════════════════════════════════════════════════════════
766
  with gr.TabItem("🏋️ Train"):
767
  gr.Markdown("### Quick Training Demo (ZeroGPU)")
 
57
  /* Status indicators */
58
  .status-online { color: #4CAF50; font-weight: bold; }
59
  .status-offline { color: #F44336; font-weight: bold; }
60
+
61
+ /* PWA install banner */
62
+ #pwa-install { display: none; position: fixed; bottom: 0; left: 0; right: 0; z-index: 10000;
63
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: white;
64
+ padding: 16px; text-align: center; box-shadow: 0 -4px 12px rgba(0,0,0,0.3); }
65
+ #pwa-install button { background: #2196F3; color: white; border: none; padding: 12px 24px;
66
+ border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; margin: 0 8px; }
67
+ #pwa-install .dismiss { background: transparent; color: #999; }
68
+
69
+ /* Auth tokens section */
70
+ .token-input input { font-family: monospace !important; letter-spacing: 1px; }
71
+ """
72
+
73
+
74
+ # =============================================================================
75
+ # PWA + SERVICE WORKER + NOTIFICATIONS + DEVICE ACCESS
76
+ # =============================================================================
77
+
78
+ PWA_HEAD_HTML = """
79
+ <script>
80
+ // ═══════════════════════════════════════════════════════════
81
+ // PWA MANIFEST (inline, no separate file needed on HF Spaces)
82
+ // ═══════════════════════════════════════════════════════════
83
+ const manifest = {
84
+ name: "Neon VLA",
85
+ short_name: "Neon",
86
+ description: "Teaching robots to see time — VLA for humanoid control",
87
+ start_url: window.location.href.split('?')[0],
88
+ display: "standalone",
89
+ background_color: "#1a1a2e",
90
+ theme_color: "#2196F3",
91
+ orientation: "any",
92
+ icons: [
93
+ { src: "https://huggingface.co/spaces/cagataydev/neon-vla/resolve/main/icon-192.png",
94
+ sizes: "192x192", type: "image/png" },
95
+ { src: "https://huggingface.co/spaces/cagataydev/neon-vla/resolve/main/icon-512.png",
96
+ sizes: "512x512", type: "image/png" },
97
+ ],
98
+ categories: ["robotics", "ai", "science"],
99
+ };
100
+ // Inject manifest as blob URL
101
+ const manifestBlob = new Blob([JSON.stringify(manifest)], {type: 'application/json'});
102
+ const manifestUrl = URL.createObjectURL(manifestBlob);
103
+ const link = document.createElement('link');
104
+ link.rel = 'manifest';
105
+ link.href = manifestUrl;
106
+ document.head.appendChild(link);
107
+
108
+ // Meta tags for iOS PWA
109
+ const metas = [
110
+ ['apple-mobile-web-app-capable', 'yes'],
111
+ ['apple-mobile-web-app-status-bar-style', 'black-translucent'],
112
+ ['apple-mobile-web-app-title', 'Neon VLA'],
113
+ ['theme-color', '#2196F3'],
114
+ ['viewport', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover'],
115
+ ];
116
+ metas.forEach(([name, content]) => {
117
+ let m = document.createElement('meta');
118
+ m.name = name; m.content = content;
119
+ document.head.appendChild(m);
120
+ });
121
+
122
+ // ═══════════════════════════════════════════════════════════
123
+ // SERVICE WORKER (inline registration)
124
+ // ═══════════════════════════════════════════════════════════
125
+ const swCode = `
126
+ self.addEventListener('install', e => { self.skipWaiting(); });
127
+ self.addEventListener('activate', e => { e.waitUntil(clients.claim()); });
128
+ self.addEventListener('push', e => {
129
+ const data = e.data ? e.data.json() : { title: 'Neon VLA', body: 'Notification' };
130
+ e.waitUntil(self.registration.showNotification(data.title, {
131
+ body: data.body, icon: '/favicon.ico', badge: '/favicon.ico',
132
+ vibrate: [200, 100, 200], tag: data.tag || 'neon',
133
+ data: data.url || '/',
134
+ }));
135
+ });
136
+ self.addEventListener('notificationclick', e => {
137
+ e.notification.close();
138
+ e.waitUntil(clients.openWindow(e.notification.data));
139
+ });
140
+ `;
141
+ if ('serviceWorker' in navigator) {
142
+ const swBlob = new Blob([swCode], {type: 'application/javascript'});
143
+ const swUrl = URL.createObjectURL(swBlob);
144
+ navigator.serviceWorker.register(swUrl, {scope: '/'}).catch(() => {
145
+ // Blob SW may fail on some browsers; that's ok, non-critical
146
+ console.log('SW registration skipped (blob URL not supported in this context)');
147
+ });
148
+ }
149
+
150
+ // ═══════════════════════════════════════════════════════════
151
+ // PWA INSTALL PROMPT
152
+ // ═══════════════════════════════════════════════════════════
153
+ let deferredPrompt = null;
154
+ window.addEventListener('beforeinstallprompt', e => {
155
+ e.preventDefault();
156
+ deferredPrompt = e;
157
+ document.getElementById('pwa-install').style.display = 'block';
158
+ });
159
+ function installPWA() {
160
+ if (!deferredPrompt) return;
161
+ deferredPrompt.prompt();
162
+ deferredPrompt.userChoice.then(r => {
163
+ document.getElementById('pwa-install').style.display = 'none';
164
+ deferredPrompt = null;
165
+ });
166
+ }
167
+ function dismissInstall() {
168
+ document.getElementById('pwa-install').style.display = 'none';
169
+ }
170
+
171
+ // ═══════════════════════════════════════════════════════════
172
+ // NOTIFICATIONS API
173
+ // ═══════════════════════════════════════════════════════════
174
+ window.neonNotify = async function(title, body, tag) {
175
+ if (!('Notification' in window)) return;
176
+ if (Notification.permission === 'default') await Notification.requestPermission();
177
+ if (Notification.permission === 'granted') {
178
+ new Notification(title, { body: body, icon: '/favicon.ico', tag: tag || 'neon',
179
+ vibrate: [200, 100, 200] });
180
+ }
181
+ };
182
+
183
+ // ═══════════════════════════════════════════════════════════
184
+ // TOKEN STORAGE (localStorage, never sent to server)
185
+ // ═══════════════════════════════════════════════════════════
186
+ window.neonTokens = {
187
+ save: function(key, value) { localStorage.setItem('neon_' + key, value); },
188
+ get: function(key) { return localStorage.getItem('neon_' + key) || ''; },
189
+ clear: function(key) { localStorage.removeItem('neon_' + key); },
190
+ };
191
+ </script>
192
+
193
+ <!-- PWA install banner -->
194
+ <div id="pwa-install">
195
+ <div style="font-weight:600; font-size:16px; margin-bottom:8px;">📱 Install Neon VLA</div>
196
+ <div style="font-size:13px; color:#ccc; margin-bottom:12px;">Add to home screen for offline access & push notifications</div>
197
+ <button onclick="installPWA()">⬇️ Install App</button>
198
+ <button class="dismiss" onclick="dismissInstall()">Not now</button>
199
+ </div>
200
+ """
201
+
202
+
203
+ # =============================================================================
204
+ # DATA CAPTURE — iPhone LiDAR, Camera, Video upload
205
+ # =============================================================================
206
+
207
+ def get_capture_html():
208
+ """HTML for iPhone data capture: LiDAR, camera, video upload."""
209
+ return """
210
+ <div id="capture-panel" style="font-family: -apple-system, system-ui, sans-serif;">
211
+ <style>
212
+ .cap-section { background: #f8f9fa; border-radius: 16px; padding: 16px; margin: 12px 0; }
213
+ .cap-btn { width: 100%; min-height: 56px; font-size: 16px; font-weight: 600;
214
+ border-radius: 14px; border: 2px solid #ddd; cursor: pointer; margin: 6px 0;
215
+ display: flex; align-items: center; justify-content: center; gap: 8px; background: white; }
216
+ .cap-btn:active { transform: scale(0.97); background: #E3F2FD; }
217
+ .cap-btn.recording { background: #FFEBEE; border-color: #F44336; animation: pulse 1s infinite; }
218
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.7; } }
219
+ .cap-status { font-size: 13px; color: #666; text-align: center; margin: 4px 0; }
220
+ .cap-progress { height: 4px; background: #eee; border-radius: 2px; margin: 8px 0; overflow: hidden; }
221
+ .cap-progress-bar { height: 100%; background: #4CAF50; border-radius: 2px; transition: width 0.3s; width: 0%; }
222
+ .token-row { display: flex; gap: 8px; margin: 8px 0; align-items: center; }
223
+ .token-row input { flex: 1; padding: 12px; border-radius: 10px; border: 1.5px solid #ddd;
224
+ font-size: 14px; font-family: monospace; }
225
+ .token-row button { min-width: 60px; padding: 12px; border-radius: 10px; border: none;
226
+ background: #4CAF50; color: white; font-weight: 600; cursor: pointer; }
227
+ .token-row button.delete { background: #F44336; min-width: 40px; }
228
+ .saved-badge { display: inline-block; background: #E8F5E9; color: #2E7D32; padding: 2px 8px;
229
+ border-radius: 6px; font-size: 12px; font-weight: 600; }
230
+ .upload-zone { border: 2px dashed #ccc; border-radius: 16px; padding: 32px; text-align: center;
231
+ cursor: pointer; transition: all 0.2s; margin: 8px 0; }
232
+ .upload-zone:hover, .upload-zone.dragover { border-color: #2196F3; background: #E3F2FD; }
233
+ .upload-zone input { display: none; }
234
+ #lidar-canvas { width: 100%; height: 200px; background: #000; border-radius: 12px; }
235
+ </style>
236
+
237
+ <!-- ══════ AUTH TOKENS ══════ -->
238
+ <div class="cap-section">
239
+ <h3 style="margin:0 0 8px">🔑 Authentication</h3>
240
+ <p style="font-size:13px; color:#888; margin:0 0 12px">Tokens stored locally in your browser only — never sent to our server.</p>
241
+
242
+ <label style="font-size:13px; font-weight:600;">HuggingFace Token</label>
243
+ <div class="token-row">
244
+ <input type="password" id="hf-token" placeholder="hf_..." oninput="checkTokenSaved('hf')">
245
+ <button onclick="saveToken('hf')">Save</button>
246
+ <button class="delete" onclick="clearToken('hf')">✕</button>
247
+ </div>
248
+ <div class="cap-status" id="hf-status"></div>
249
+
250
+ <label style="font-size:13px; font-weight:600;">GitHub Token</label>
251
+ <div class="token-row">
252
+ <input type="password" id="gh-token" placeholder="ghp_..." oninput="checkTokenSaved('gh')">
253
+ <button onclick="saveToken('gh')">Save</button>
254
+ <button class="delete" onclick="clearToken('gh')">✕</button>
255
+ </div>
256
+ <div class="cap-status" id="gh-status"></div>
257
+
258
+ <label style="font-size:13px; font-weight:600;">Robot WebSocket URL</label>
259
+ <div class="token-row">
260
+ <input type="text" id="robot-url" placeholder="ws://192.168.123.10:10100" oninput="checkTokenSaved('robot')">
261
+ <button onclick="saveToken('robot')">Save</button>
262
+ <button class="delete" onclick="clearToken('robot')">✕</button>
263
+ </div>
264
+ <div class="cap-status" id="robot-status"></div>
265
+ </div>
266
+
267
+ <!-- ══════ IPHONE LIDAR CAPTURE ══════ -->
268
+ <div class="cap-section">
269
+ <h3 style="margin:0 0 8px">📡 iPhone LiDAR Scanner</h3>
270
+ <p style="font-size:13px; color:#888; margin:0 0 8px">Requires iPhone 12 Pro+ or iPad Pro with LiDAR. Uses WebXR Depth API.</p>
271
+
272
+ <canvas id="lidar-canvas"></canvas>
273
+ <div class="cap-status" id="lidar-status">Tap to start LiDAR scanning</div>
274
+ <div class="cap-progress"><div class="cap-progress-bar" id="lidar-progress"></div></div>
275
+
276
+ <button class="cap-btn" id="lidar-btn" onclick="toggleLidar()">
277
+ 📡 Start LiDAR Scan
278
+ </button>
279
+ <button class="cap-btn" onclick="downloadLidarData()" style="background:#E8F5E9;">
280
+ 💾 Download Point Cloud (.ply)
281
+ </button>
282
+ </div>
283
+
284
+ <!-- ══════ CAMERA CAPTURE ══════ -->
285
+ <div class="cap-section">
286
+ <h3 style="margin:0 0 8px">📹 Camera Recording</h3>
287
+ <p style="font-size:13px; color:#888; margin:0 0 8px">Record episodes from your phone camera for robot training data.</p>
288
+
289
+ <video id="camera-preview" autoplay playsinline muted
290
+ style="width:100%; border-radius:12px; background:#000; max-height:300px; object-fit:cover;"></video>
291
+ <div class="cap-status" id="camera-status">Tap to start camera</div>
292
+
293
+ <button class="cap-btn" id="camera-btn" onclick="toggleCamera()">
294
+ 📹 Start Camera
295
+ </button>
296
+ <button class="cap-btn" id="record-btn" onclick="toggleRecording()" style="display:none;">
297
+ 🔴 Record Episode
298
+ </button>
299
+ <div class="cap-status" id="record-status"></div>
300
+ </div>
301
+
302
+ <!-- ══════ VIDEO UPLOAD ══════ -->
303
+ <div class="cap-section">
304
+ <h3 style="margin:0 0 8px">📤 Upload Training Video</h3>
305
+ <p style="font-size:13px; color:#888; margin:0 0 8px">Upload demonstration videos to create training episodes.</p>
306
+
307
+ <div class="upload-zone" id="upload-zone" onclick="document.getElementById('video-input').click()"
308
+ ondragover="event.preventDefault(); this.classList.add('dragover')"
309
+ ondragleave="this.classList.remove('dragover')"
310
+ ondrop="event.preventDefault(); this.classList.remove('dragover'); handleVideoDrop(event)">
311
+ <div style="font-size:48px;">🎬</div>
312
+ <div style="font-size:15px; font-weight:600; margin:8px 0;">Tap to select or drag video</div>
313
+ <div style="font-size:13px; color:#999;">MP4, MOV, WebM · Max 500MB</div>
314
+ <input type="file" id="video-input" accept="video/*" onchange="handleVideoSelect(event)">
315
+ </div>
316
+ <div class="cap-progress"><div class="cap-progress-bar" id="upload-progress"></div></div>
317
+ <div class="cap-status" id="upload-status"></div>
318
+ </div>
319
+
320
+ <!-- ══════ PUSH NOTIFICATIONS ══════ -->
321
+ <div class="cap-section">
322
+ <h3 style="margin:0 0 8px">🔔 Notifications</h3>
323
+ <button class="cap-btn" onclick="enableNotifications()">
324
+ 🔔 Enable Push Notifications
325
+ </button>
326
+ <div class="cap-status" id="notif-status"></div>
327
+ <button class="cap-btn" onclick="testNotification()" style="background:#FFF3E0;">
328
+ 🧪 Test Notification
329
+ </button>
330
+ </div>
331
+
332
+ <script>
333
+ // ── Token Management ────────────────────────────────
334
+ function saveToken(type) {
335
+ const input = document.getElementById(type === 'hf' ? 'hf-token' : type === 'gh' ? 'gh-token' : 'robot-url');
336
+ if (!input.value) return;
337
+ neonTokens.save(type + '_token', input.value);
338
+ document.getElementById(type + '-status').innerHTML = '<span class="saved-badge">✅ Saved securely</span>';
339
+ // Also inject into Gradio state if needed
340
+ if (type === 'hf') window._neonHfToken = input.value;
341
+ if (type === 'gh') window._neonGhToken = input.value;
342
+ }
343
+ function clearToken(type) {
344
+ neonTokens.clear(type + '_token');
345
+ const input = document.getElementById(type === 'hf' ? 'hf-token' : type === 'gh' ? 'gh-token' : 'robot-url');
346
+ input.value = '';
347
+ document.getElementById(type + '-status').textContent = 'Cleared';
348
+ }
349
+ function checkTokenSaved(type) {
350
+ // Real-time feedback
351
+ }
352
+ // Restore tokens on load
353
+ window.addEventListener('load', () => {
354
+ ['hf', 'gh', 'robot'].forEach(type => {
355
+ const val = neonTokens.get(type + '_token');
356
+ if (val) {
357
+ const input = document.getElementById(type === 'hf' ? 'hf-token' : type === 'gh' ? 'gh-token' : 'robot-url');
358
+ input.value = val;
359
+ document.getElementById(type + '-status').innerHTML = '<span class="saved-badge">✅ Saved</span>';
360
+ if (type === 'hf') window._neonHfToken = val;
361
+ if (type === 'gh') window._neonGhToken = val;
362
+ }
363
+ });
364
+ });
365
+
366
+ // ── LiDAR (WebXR Depth API) ────────────────────────
367
+ let xrSession = null;
368
+ let lidarPoints = [];
369
+ let lidarRunning = false;
370
+
371
+ async function toggleLidar() {
372
+ const btn = document.getElementById('lidar-btn');
373
+ if (lidarRunning) {
374
+ if (xrSession) xrSession.end();
375
+ lidarRunning = false;
376
+ btn.textContent = '📡 Start LiDAR Scan';
377
+ btn.classList.remove('recording');
378
+ document.getElementById('lidar-status').textContent = `Captured ${lidarPoints.length} points`;
379
+ return;
380
+ }
381
+
382
+ // Check WebXR support
383
+ if (!navigator.xr) {
384
+ document.getElementById('lidar-status').textContent = '❌ WebXR not supported. Use iPhone 12 Pro+ Safari.';
385
+ return;
386
+ }
387
+
388
+ try {
389
+ const supported = await navigator.xr.isSessionSupported('immersive-ar');
390
+ if (!supported) {
391
+ // Fallback: use depth estimation from camera
392
+ document.getElementById('lidar-status').textContent = '⚠️ AR not supported. Using camera depth fallback...';
393
+ startCameraDepthFallback();
394
+ return;
395
+ }
396
+
397
+ xrSession = await navigator.xr.requestSession('immersive-ar', {
398
+ requiredFeatures: ['depth-sensing'],
399
+ depthSensing: { usagePreference: ['cpu-optimized'], dataFormatPreference: ['luminance-alpha'] }
400
+ });
401
+
402
+ lidarRunning = true;
403
+ lidarPoints = [];
404
+ btn.textContent = '⏹️ Stop Scanning';
405
+ btn.classList.add('recording');
406
+ document.getElementById('lidar-status').textContent = 'Scanning... move your phone slowly';
407
+
408
+ const canvas = document.getElementById('lidar-canvas');
409
+ const gl = canvas.getContext('webgl2');
410
+ const refSpace = await xrSession.requestReferenceSpace('local');
411
+
412
+ xrSession.requestAnimationFrame(function onFrame(time, frame) {
413
+ if (!lidarRunning) return;
414
+ const pose = frame.getViewerPose(refSpace);
415
+ if (pose) {
416
+ for (const view of pose.views) {
417
+ const depthInfo = frame.getDepthInformation(view);
418
+ if (depthInfo) {
419
+ // Sample depth at grid points
420
+ for (let y = 0; y < depthInfo.height; y += 4) {
421
+ for (let x = 0; x < depthInfo.width; x += 4) {
422
+ const depth = depthInfo.getDepthInMeters(x / depthInfo.width, y / depthInfo.height);
423
+ if (depth > 0 && depth < 10) {
424
+ lidarPoints.push([
425
+ (x / depthInfo.width - 0.5) * depth,
426
+ (y / depthInfo.height - 0.5) * depth,
427
+ -depth
428
+ ]);
429
+ }
430
+ }
431
+ }
432
+ document.getElementById('lidar-status').textContent = `Scanning... ${lidarPoints.length} points`;
433
+ const pct = Math.min(100, lidarPoints.length / 100);
434
+ document.getElementById('lidar-progress').style.width = pct + '%';
435
+ }
436
+ }
437
+ }
438
+ xrSession.requestAnimationFrame(onFrame);
439
+ });
440
+
441
+ xrSession.addEventListener('end', () => { lidarRunning = false; });
442
+ } catch (e) {
443
+ document.getElementById('lidar-status').textContent = '❌ ' + e.message;
444
+ }
445
+ }
446
+
447
+ function startCameraDepthFallback() {
448
+ // Use regular camera as fallback — records video frames for monocular depth estimation
449
+ document.getElementById('lidar-status').textContent = 'Using camera video → will estimate depth server-side';
450
+ toggleCamera();
451
+ }
452
+
453
+ function downloadLidarData() {
454
+ if (!lidarPoints.length) {
455
+ document.getElementById('lidar-status').textContent = 'No points captured yet';
456
+ return;
457
+ }
458
+ // Export as PLY format
459
+ let ply = `ply\\nformat ascii 1.0\\nelement vertex ${lidarPoints.length}\\n`;
460
+ ply += 'property float x\\nproperty float y\\nproperty float z\\nend_header\\n';
461
+ lidarPoints.forEach(p => { ply += `${p[0].toFixed(4)} ${p[1].toFixed(4)} ${p[2].toFixed(4)}\\n`; });
462
+ const blob = new Blob([ply], {type: 'application/octet-stream'});
463
+ const a = document.createElement('a');
464
+ a.href = URL.createObjectURL(blob);
465
+ a.download = `neon-lidar-${Date.now()}.ply`;
466
+ a.click();
467
+ document.getElementById('lidar-status').textContent = `Downloaded ${lidarPoints.length} points`;
468
+ }
469
+
470
+ // ── Camera Recording ────────────────────────────────
471
+ let cameraStream = null;
472
+ let mediaRecorder = null;
473
+ let recordedChunks = [];
474
+ let isRecording = false;
475
+
476
+ async function toggleCamera() {
477
+ const btn = document.getElementById('camera-btn');
478
+ const recordBtn = document.getElementById('record-btn');
479
+ const preview = document.getElementById('camera-preview');
480
+
481
+ if (cameraStream) {
482
+ cameraStream.getTracks().forEach(t => t.stop());
483
+ cameraStream = null;
484
+ preview.srcObject = null;
485
+ btn.textContent = '📹 Start Camera';
486
+ recordBtn.style.display = 'none';
487
+ document.getElementById('camera-status').textContent = 'Camera stopped';
488
+ return;
489
+ }
490
+
491
+ try {
492
+ cameraStream = await navigator.mediaDevices.getUserMedia({
493
+ video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
494
+ audio: true,
495
+ });
496
+ preview.srcObject = cameraStream;
497
+ btn.textContent = '⏹️ Stop Camera';
498
+ recordBtn.style.display = 'block';
499
+ document.getElementById('camera-status').textContent = 'Camera active — tap Record to capture an episode';
500
+ } catch (e) {
501
+ document.getElementById('camera-status').textContent = '❌ ' + e.message;
502
+ }
503
+ }
504
+
505
+ function toggleRecording() {
506
+ const btn = document.getElementById('record-btn');
507
+ if (isRecording) {
508
+ mediaRecorder.stop();
509
+ isRecording = false;
510
+ btn.textContent = '🔴 Record Episode';
511
+ btn.classList.remove('recording');
512
+ return;
513
+ }
514
+ if (!cameraStream) return;
515
+ recordedChunks = [];
516
+ mediaRecorder = new MediaRecorder(cameraStream, { mimeType: 'video/webm;codecs=vp9' });
517
+ mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recordedChunks.push(e.data); };
518
+ mediaRecorder.onstop = () => {
519
+ const blob = new Blob(recordedChunks, { type: 'video/webm' });
520
+ const url = URL.createObjectURL(blob);
521
+ const a = document.createElement('a');
522
+ a.href = url;
523
+ a.download = `neon-episode-${Date.now()}.webm`;
524
+ a.click();
525
+ document.getElementById('record-status').textContent = `Episode saved (${(blob.size/1e6).toFixed(1)} MB)`;
526
+ if (window.neonNotify) neonNotify('Neon VLA', 'Episode recorded successfully!', 'record');
527
+ };
528
+ mediaRecorder.start(100);
529
+ isRecording = true;
530
+ btn.textContent = '⏹️ Stop Recording';
531
+ btn.classList.add('recording');
532
+ document.getElementById('record-status').textContent = 'Recording...';
533
+ // Track duration
534
+ let sec = 0;
535
+ const timer = setInterval(() => {
536
+ if (!isRecording) { clearInterval(timer); return; }
537
+ sec++;
538
+ document.getElementById('record-status').textContent = `Recording... ${sec}s`;
539
+ }, 1000);
540
+ }
541
+
542
+ // ── Video Upload ────────────────────────────────────
543
+ function handleVideoSelect(e) { uploadVideo(e.target.files[0]); }
544
+ function handleVideoDrop(e) { uploadVideo(e.dataTransfer.files[0]); }
545
+
546
+ async function uploadVideo(file) {
547
+ if (!file) return;
548
+ const status = document.getElementById('upload-status');
549
+ const progress = document.getElementById('upload-progress');
550
+
551
+ if (file.size > 500 * 1024 * 1024) {
552
+ status.textContent = '❌ File too large (max 500MB)';
553
+ return;
554
+ }
555
+
556
+ status.textContent = `Uploading ${file.name} (${(file.size/1e6).toFixed(1)} MB)...`;
557
+
558
+ // If HF token available, upload to HF dataset
559
+ const hfToken = neonTokens.get('hf_token');
560
+ if (hfToken) {
561
+ try {
562
+ const formData = new FormData();
563
+ formData.append('file', file);
564
+
565
+ // Use HF Hub API to upload
566
+ const resp = await fetch(
567
+ `https://huggingface.co/api/datasets/cagataydev/neon-episodes/upload/main/${file.name}`,
568
+ {
569
+ method: 'POST',
570
+ headers: { 'Authorization': `Bearer ${hfToken}` },
571
+ body: formData,
572
+ }
573
+ );
574
+
575
+ if (resp.ok) {
576
+ status.textContent = `✅ Uploaded to HuggingFace: cagataydev/neon-episodes/${file.name}`;
577
+ progress.style.width = '100%';
578
+ if (window.neonNotify) neonNotify('Neon VLA', `Video uploaded: ${file.name}`, 'upload');
579
+ } else {
580
+ status.textContent = `⚠️ Upload failed (${resp.status}). Downloading locally instead.`;
581
+ downloadVideoLocally(file);
582
+ }
583
+ } catch (e) {
584
+ status.textContent = `⚠️ Upload error. Downloading locally.`;
585
+ downloadVideoLocally(file);
586
+ }
587
+ } else {
588
+ status.textContent = '💡 Add HF Token above to upload directly to HuggingFace. Downloading locally...';
589
+ downloadVideoLocally(file);
590
+ }
591
+ }
592
+
593
+ function downloadVideoLocally(file) {
594
+ const url = URL.createObjectURL(file);
595
+ const a = document.createElement('a');
596
+ a.href = url; a.download = file.name; a.click();
597
+ document.getElementById('upload-status').textContent = `Saved locally: ${file.name}`;
598
+ }
599
+
600
+ // ── Push Notifications ──────────────────────────────
601
+ async function enableNotifications() {
602
+ const status = document.getElementById('notif-status');
603
+ if (!('Notification' in window)) {
604
+ status.textContent = '❌ Notifications not supported in this browser';
605
+ return;
606
+ }
607
+ const perm = await Notification.requestPermission();
608
+ if (perm === 'granted') {
609
+ status.innerHTML = '<span class="saved-badge">✅ Notifications enabled</span>';
610
+ neonNotify('Neon VLA', 'Notifications are working!', 'test');
611
+ } else {
612
+ status.textContent = '❌ Permission denied. Enable in browser settings.';
613
+ }
614
+ }
615
+
616
+ function testNotification() {
617
+ if (window.neonNotify) {
618
+ neonNotify('🤖 Training Complete', 'neon-g1-v1 finished training. Loss: 0.0023', 'train');
619
+ }
620
+ }
621
+ </script>
622
+ </div>
623
  """
624
 
625
 
 
1272
  title="Neon VLA",
1273
  theme=gr.themes.Soft(),
1274
  css=MOBILE_CSS,
1275
+ head=PWA_HEAD_HTML,
1276
  ) as demo:
1277
 
1278
  # Header — compact for phone
 
1325
  gr.HTML(get_robot_control_html())
1326
 
1327
  # ═══════════════════════════════════════════════════════════
1328
+ # TAB 3: CAPTURELiDAR, camera, video upload, auth
1329
+ # ═══════════════════════════════════════════════════════════
1330
+ with gr.TabItem("📱 Capture"):
1331
+ gr.Markdown("### Record Data from Your Phone")
1332
+ gr.HTML(get_capture_html())
1333
+
1334
+ # ═══════════════════════════════════════════════════════════
1335
+ # TAB 4: TRAIN — simplified training interface
1336
  # ═══════════════════════════════════════════════════════════
1337
  with gr.TabItem("🏋️ Train"):
1338
  gr.Markdown("### Quick Training Demo (ZeroGPU)")