diff --git a/examples/openarms_web_interface/App.css b/examples/openarms_web_interface/App.css
new file mode 100644
index 000000000..c97eead42
--- /dev/null
+++ b/examples/openarms_web_interface/App.css
@@ -0,0 +1,481 @@
+body {
+ margin: 0;
+ padding: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: #f5f5f5;
+}
+
+main {
+ min-height: 100vh;
+ padding: 2rem;
+}
+
+header {
+ text-align: center;
+ margin-bottom: 2rem;
+}
+
+h1 {
+ font-size: 2rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0;
+}
+
+h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #333;
+ margin: 0 0 1rem 0;
+}
+
+h3 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #666;
+ margin: 0 0 0.5rem 0;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.container {
+ max-width: 1600px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.panel {
+ background: white;
+ border-radius: 8px;
+ padding: 1.5rem;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.config-panel {
+ border: 2px solid #e5e7eb;
+}
+
+.config-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ padding: 0.5rem 0;
+}
+
+.config-header:hover {
+ opacity: 0.7;
+}
+
+.toggle-icon {
+ font-size: 1rem;
+ color: #6b7280;
+ transition: transform 0.2s;
+}
+
+.config-content {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.robot-setup {
+ margin-bottom: 0.5rem;
+}
+
+.robot-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ border-radius: 6px;
+ font-weight: 500;
+ gap: 1rem;
+}
+
+.robot-status.ready {
+ background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
+ color: #065f46;
+ border: 1px solid #10b981;
+}
+
+.robot-status.not-ready {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ color: #92400e;
+ border: 1px solid #f59e0b;
+}
+
+.btn-setup {
+ background: #10b981;
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-setup:hover:not(:disabled) {
+ background: #059669;
+}
+
+.btn-setup:disabled {
+ background: #d1d5db;
+ cursor: not-allowed;
+}
+
+.btn-disconnect {
+ background: #ef4444;
+ color: white;
+ border: none;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-disconnect:hover {
+ background: #dc2626;
+}
+
+.btn-refresh {
+ background: #3b82f6;
+ color: white;
+ border: none;
+ padding: 0.4rem 0.8rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-refresh:hover:not(:disabled) {
+ background: #2563eb;
+}
+
+.btn-refresh:disabled {
+ background: #d1d5db;
+ cursor: not-allowed;
+}
+
+.control-panel {
+ border: 2px solid #10b981;
+}
+
+.status-banner {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+ border-radius: 6px;
+ margin-bottom: 1.5rem;
+ font-weight: 500;
+ font-size: 0.95rem;
+}
+
+.status-banner.initializing {
+ background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
+ color: #1e40af;
+ border-left: 4px solid #3b82f6;
+}
+
+.status-banner.encoding {
+ background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+ color: #92400e;
+ border-left: 4px solid #f59e0b;
+}
+
+.status-banner.uploading {
+ background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
+ color: #3730a3;
+ border-left: 4px solid #6366f1;
+}
+
+.status-banner.success {
+ background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
+ color: #065f46;
+ border-left: 4px solid #10b981;
+}
+
+.status-banner.warning {
+ background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
+ color: #991b1b;
+ border-left: 4px solid #ef4444;
+}
+
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 3px solid rgba(0, 0, 0, 0.1);
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.control-horizontal {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+ gap: 2rem;
+ align-items: start;
+}
+
+.control-left {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.control-right {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.input-group {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 0;
+}
+
+input[type="text"] {
+ flex: 1;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+input[type="text"]:disabled {
+ background: #f5f5f5;
+ cursor: not-allowed;
+}
+
+input[type="text"]:focus {
+ outline: none;
+ border-color: #10b981;
+}
+
+button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-start {
+ background: #10b981;
+ color: white;
+}
+
+.btn-start:hover:not(:disabled) {
+ background: #059669;
+}
+
+.btn-start:disabled {
+ background: #d1d5db;
+ cursor: not-allowed;
+}
+
+.btn-stop {
+ background: #ef4444;
+ color: white;
+}
+
+.btn-stop:hover {
+ background: #dc2626;
+}
+
+.btn-reset {
+ padding: 0.5rem 1rem;
+ background: #6b7280;
+ color: white;
+ font-size: 0.875rem;
+}
+
+.btn-reset:hover {
+ background: #4b5563;
+}
+
+.status {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.status.recording {
+ background: #fee2e2;
+ color: #991b1b;
+}
+
+.status.idle {
+ background: #f3f4f6;
+ color: #374151;
+}
+
+.indicator {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #ef4444;
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.counter {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 1.5rem;
+ background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
+ border-radius: 8px;
+ border: 2px solid #e5e7eb;
+ min-width: 200px;
+}
+
+.counter-label {
+ font-size: 0.75rem;
+ color: #6b7280;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.counter-value {
+ font-size: 3rem;
+ font-weight: 700;
+ color: #10b981;
+ line-height: 1;
+}
+
+.time-display {
+ font-size: 1.5rem;
+ font-weight: 600;
+ font-family: 'Courier New', monospace;
+}
+
+.error-box {
+ padding: 1rem;
+ background: #fee2e2;
+ color: #991b1b;
+ border-radius: 4px;
+ border-left: 4px solid #ef4444;
+ font-size: 0.875rem;
+}
+
+.config-section {
+ margin-bottom: 1.5rem;
+}
+
+.config-section:last-child {
+ margin-bottom: 0;
+}
+
+.config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1rem;
+}
+
+label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ font-size: 0.875rem;
+ color: #374151;
+ font-weight: 500;
+}
+
+select {
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ background: white;
+}
+
+select:disabled {
+ background: #f5f5f5;
+ cursor: not-allowed;
+}
+
+.camera-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 1rem;
+}
+
+.camera {
+ border: 1px solid #e5e7eb;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.camera h3 {
+ padding: 0.75rem;
+ background: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+ margin: 0;
+}
+
+.camera img {
+ width: 100%;
+ height: auto;
+ display: block;
+ background: #000;
+ min-height: 300px;
+ object-fit: cover;
+}
+
+.camera-placeholder {
+ text-align: center;
+ padding: 4rem 2rem;
+ background: #f9fafb;
+ border-radius: 4px;
+ border: 2px dashed #d1d5db;
+}
+
+.camera-placeholder p {
+ margin: 0.5rem 0;
+ font-size: 1rem;
+ color: #6b7280;
+}
+
+.camera-placeholder p:first-child {
+ font-size: 1.25rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+.hint {
+ margin-top: 0.5rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
diff --git a/examples/openarms_web_interface/App.jsx b/examples/openarms_web_interface/App.jsx
new file mode 100644
index 000000000..4573c7a60
--- /dev/null
+++ b/examples/openarms_web_interface/App.jsx
@@ -0,0 +1,562 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import './App.css';
+
+const API_BASE = 'http://localhost:8000/api';
+
+function App() {
+ // State
+ const [task, setTask] = useState('');
+ const [isRecording, setIsRecording] = useState(false);
+ const [isInitializing, setIsInitializing] = useState(false);
+ const [isEncoding, setIsEncoding] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [robotsReady, setRobotsReady] = useState(false);
+ const [elapsedTime, setElapsedTime] = useState(0);
+ const [currentFps, setCurrentFps] = useState(0);
+ const [episodeCount, setEpisodeCount] = useState(0);
+ const [error, setError] = useState(null);
+ const [statusMessage, setStatusMessage] = useState('Ready');
+ const [uploadStatus, setUploadStatus] = useState(null);
+ const [configExpanded, setConfigExpanded] = useState(false);
+
+ // Configuration
+ const [config, setConfig] = useState({
+ leader_left: 'can0',
+ leader_right: 'can1',
+ follower_left: 'can2',
+ follower_right: 'can3',
+ left_wrist: '/dev/video0',
+ right_wrist: '/dev/video1',
+ base: '/dev/video4'
+ });
+
+ // Available options
+ const [availableCameras, setAvailableCameras] = useState([]);
+ const canInterfaces = ['can0', 'can1', 'can2', 'can3'];
+
+ const statusIntervalRef = useRef(null);
+ const hasInitializedRef = useRef(false);
+
+ const loadConfig = () => {
+ try {
+ const saved = localStorage.getItem('openarms_config');
+ if (saved) {
+ setConfig(prev => ({ ...prev, ...JSON.parse(saved) }));
+ }
+ } catch (e) {
+ console.error('Load config error:', e);
+ }
+ };
+
+ const saveConfig = (newConfig) => {
+ try {
+ localStorage.setItem('openarms_config', JSON.stringify(newConfig || config));
+ } catch (e) {
+ console.error('Save config error:', e);
+ }
+ };
+
+ // Fetch status periodically
+ const fetchStatus = async () => {
+ try {
+ const response = await fetch(`${API_BASE}/status`);
+ const data = await response.json();
+
+ setIsRecording(data.is_recording);
+ setIsInitializing(data.is_initializing);
+ setIsEncoding(data.is_encoding);
+ setIsUploading(data.is_uploading);
+ setRobotsReady(data.robots_ready);
+ setElapsedTime(data.elapsed_time);
+ setCurrentFps(data.current_fps || 0);
+ setEpisodeCount(data.episode_count);
+ setError(data.error);
+ setStatusMessage(data.status_message || 'Ready');
+ setUploadStatus(data.upload_status);
+
+ if (data.config) {
+ // Only merge server config if we don't have a saved config (first load)
+ if (!localStorage.getItem('openarms_config')) {
+ setConfig(prev => {
+ const merged = { ...data.config, ...prev };
+ localStorage.setItem('openarms_config', JSON.stringify(merged));
+ return merged;
+ });
+ }
+ }
+ } catch (e) {
+ console.error('Failed to fetch status:', e);
+ }
+ };
+
+ const setupRobots = async () => {
+ setError(null);
+ try {
+ const response = await fetch(`${API_BASE}/robots/setup`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config)
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || 'Failed to setup robots');
+ }
+
+ await response.json();
+ saveConfig(config);
+ } catch (e) {
+ setError(`Robot setup failed: ${e.message}`);
+ }
+ };
+
+ // Disconnect robots
+ const disconnectRobots = async () => {
+ try {
+ await fetch(`${API_BASE}/robots/disconnect`, { method: 'POST' });
+ setRobotsReady(false);
+ } catch (e) {
+ console.error('Failed to disconnect robots:', e);
+ }
+ };
+
+ // Discover cameras
+ const discoverCameras = async () => {
+ try {
+ const response = await fetch(`${API_BASE}/cameras/discover`);
+ const data = await response.json();
+ const cameras = data.cameras || [];
+ setAvailableCameras(cameras);
+
+ // Get list of valid camera IDs
+ const validCameraIds = cameras.map(cam => String(cam.id));
+
+ // Auto-fix config if current values are invalid or not set
+ const updated = { ...config };
+ let changed = false;
+
+ // Auto-fix invalid camera config
+ if (!config.left_wrist || !validCameraIds.includes(config.left_wrist)) {
+ if (cameras.length >= 1) {
+ updated.left_wrist = String(cameras[0].id);
+ changed = true;
+ }
+ }
+
+ if (!config.right_wrist || !validCameraIds.includes(config.right_wrist)) {
+ if (cameras.length >= 2) {
+ updated.right_wrist = String(cameras[1].id);
+ changed = true;
+ }
+ }
+
+ if (!config.base || !validCameraIds.includes(config.base)) {
+ if (cameras.length >= 3) {
+ updated.base = String(cameras[2].id);
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ setConfig(updated);
+ saveConfig(updated);
+ }
+
+ if (cameras.length === 0) {
+ setError('No cameras detected! Please connect cameras and refresh.');
+ }
+ } catch (e) {
+ console.error('Failed to discover cameras:', e);
+ setError(`Camera discovery failed: ${e.message}`);
+ }
+ };
+
+ // Start recording
+ const startRecording = async () => {
+ if (!task.trim()) {
+ setError('Please enter a task description');
+ return;
+ }
+
+ setError(null);
+
+ try {
+ const response = await fetch(`${API_BASE}/recording/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ task, ...config })
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || 'Failed to start recording');
+ }
+
+ await response.json();
+ saveConfig(config);
+ } catch (e) {
+ setError(e.message);
+ }
+ };
+
+ // Stop recording
+ const stopRecording = async () => {
+ try {
+ const response = await fetch(`${API_BASE}/recording/stop`, {
+ method: 'POST'
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || 'Failed to stop recording');
+ }
+
+ await response.json();
+ setError(null);
+ } catch (e) {
+ setError(e.message);
+ }
+ };
+
+ // Reset counter
+ const resetCounter = async () => {
+ try {
+ await fetch(`${API_BASE}/counter/reset`, { method: 'POST' });
+ setEpisodeCount(0);
+ } catch (e) {
+ console.error('Failed to reset counter:', e);
+ }
+ };
+
+ // Format time as MM:SS
+ const formatTime = (seconds) => {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ };
+
+ // Update config and save
+ const updateConfig = (key, value) => {
+ const updated = { ...config, [key]: value };
+ setConfig(updated);
+ saveConfig(updated);
+ };
+
+ // Initialize on mount only
+ useEffect(() => {
+ // Prevent double-initialization in development
+ if (hasInitializedRef.current) {
+ return;
+ }
+ hasInitializedRef.current = true;
+
+ loadConfig();
+ discoverCameras();
+ fetchStatus();
+ statusIntervalRef.current = setInterval(fetchStatus, 1000);
+
+ return () => {
+ if (statusIntervalRef.current) {
+ clearInterval(statusIntervalRef.current);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Run only once on mount
+
+ return (
+
+
+
+
+ {/* Configuration Panel */}
+
+ setConfigExpanded(!configExpanded)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => e.key === 'Enter' && setConfigExpanded(!configExpanded)}
+ >
+
⚙️ Configuration
+ {configExpanded ? '▼' : '▶'}
+
+
+ {configExpanded && (
+
+ {/* Robot Setup */}
+
+
🤖 Robot Setup
+
+ {robotsReady ? (
+
+ ✅ Robots Ready - Recording will start instantly
+
+ Disconnect Robots
+
+
+ ) : (
+
+ ⚠️ Robots not initialized - Recording will take ~10 seconds
+
+ 🚀 Setup Robots
+
+
+ )}
+
+
+
+ {/* CAN Interfaces */}
+
+
CAN Interfaces
+
+
+ Leader Left
+ updateConfig('leader_left', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {canInterfaces.map((iface) => (
+ {iface}
+ ))}
+
+
+
+
+ Leader Right
+ updateConfig('leader_right', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {canInterfaces.map((iface) => (
+ {iface}
+ ))}
+
+
+
+
+ Follower Left
+ updateConfig('follower_left', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {canInterfaces.map((iface) => (
+ {iface}
+ ))}
+
+
+
+
+ Follower Right
+ updateConfig('follower_right', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {canInterfaces.map((iface) => (
+ {iface}
+ ))}
+
+
+
+
+
+ {/* Camera Configuration */}
+
+
+
Cameras {availableCameras.length > 0 && `(${availableCameras.length} detected)`}
+
+ 🔄 Refresh
+
+
+
+
+ Left Wrist
+ updateConfig('left_wrist', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {availableCameras.map((cam) => (
+
+ {cam.name || `Camera @ ${cam.id}`}
+
+ ))}
+
+
+
+
+ Right Wrist
+ updateConfig('right_wrist', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {availableCameras.map((cam) => (
+
+ {cam.name || `Camera @ ${cam.id}`}
+
+ ))}
+
+
+
+
+ Base Camera
+ updateConfig('base', e.target.value)}
+ disabled={isRecording || robotsReady}
+ >
+ {availableCameras.map((cam) => (
+
+ {cam.name || `Camera @ ${cam.id}`}
+
+ ))}
+
+
+
+
+
+ )}
+
+
+ {/* Control Panel */}
+
+ 🎬 Recording Control
+
+ {/* Status Banner - Always show important statuses */}
+ {isInitializing && (
+
+ )}
+
+ {isEncoding && (
+
+ )}
+
+ {isUploading && (
+
+ )}
+
+ {uploadStatus && !isRecording && !isEncoding && !isUploading && (
+
+ {uploadStatus}
+
+ )}
+
+
+ {/* Task Input and Status */}
+
+
+ setTask(e.target.value)}
+ placeholder="Task description (e.g., 'pick and place')"
+ disabled={isRecording || isInitializing || isEncoding || isUploading}
+ />
+
+ {isInitializing
+ ? '⏳ Initializing...'
+ : isRecording
+ ? '⏺ Recording...'
+ : robotsReady
+ ? '⏺ Start Recording'
+ : '⏺ Setup Robots First'}
+
+
+
+ {/* Recording Status */}
+ {isRecording && (
+
+
+
+ {formatTime(elapsedTime)} @ {currentFps.toFixed(1)} FPS
+
+
+ ⏹ Stop
+
+
+ )}
+
+
+ {/* Episode Counter */}
+
+
+
Episodes Recorded
+
{episodeCount}
+
+ Reset
+
+
+
+
+
+ {/* Error Display */}
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+
+ {/* Camera Feeds */}
+
+ 📹 Camera Views
+ {robotsReady || isRecording || isInitializing ? (
+
+
+
Left Wrist
+
+
+
+
+
Base
+
+
+
+
+
Right Wrist
+
+
+
+ ) : (
+
+
📷 Camera feeds will appear when robots are set up
+
Click "Setup Robots" above to preview camera feeds
+
+ )}
+
+
+
+
+ );
+}
+
+export default App;
+
diff --git a/examples/openarms_web_interface/README.md b/examples/openarms_web_interface/README.md
new file mode 100644
index 000000000..b7d6a0126
--- /dev/null
+++ b/examples/openarms_web_interface/README.md
@@ -0,0 +1,41 @@
+# OpenArms Web Recording Interface
+
+A web interface for recording OpenArms datasets.
+
+## Installation
+
+```bash
+cd examples/openarms_web_interface
+npm install
+```
+
+## Usage
+
+**Start everything with one command:**
+
+```bash
+./launch.sh
+```
+
+This will:
+- Start the FastAPI backend on port 8000
+- Start the React frontend on port 5173
+- Show live logs from both services
+
+Then open your browser to: **http://localhost:5173**
+
+**Stop with:** `Ctrl+C`
+
+---
+
+## Workflow
+
+1. **Configure CAN interfaces** and **camera paths** in the dropdowns
+2. Click **"Setup Robots"** to initialize (once at start)
+3. Enter a **task description**
+4. Click **"Start Recording"** to begin an episode
+5. Click **"Stop Recording"** when done
+6. Dataset is automatically encoded and uploaded to HuggingFace Hub as **private**
+7. Repeat steps 3-6 for more episodes (no need to re-setup robots!)
+
+---
diff --git a/examples/openarms_web_interface/index.html b/examples/openarms_web_interface/index.html
new file mode 100644
index 000000000..96ce4338a
--- /dev/null
+++ b/examples/openarms_web_interface/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ OpenArms Recording Interface
+
+
+
+
+
+
diff --git a/examples/openarms_web_interface/launch.sh b/examples/openarms_web_interface/launch.sh
new file mode 100755
index 000000000..6bea36546
--- /dev/null
+++ b/examples/openarms_web_interface/launch.sh
@@ -0,0 +1,125 @@
+#!/bin/bash
+
+# OpenArms Web Interface Launcher
+# Starts Rerun viewer, FastAPI backend, and React frontend
+
+set -e
+
+# Colors for output
+GREEN='\033[0;32m'
+BLUE='\033[0;34m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+# Get script directory
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+cd "$SCRIPT_DIR"
+
+echo -e "${BLUE}╔════════════════════════════════════════╗${NC}"
+echo -e "${BLUE}║ OpenArms Web Recording Interface ║${NC}"
+echo -e "${BLUE}╚════════════════════════════════════════╝${NC}"
+echo ""
+
+# Function to cleanup on exit
+cleanup() {
+ echo ""
+ echo -e "${YELLOW}Shutting down services...${NC}"
+
+ # Kill all child processes
+ pkill -P $$ 2>/dev/null || true
+
+ # Kill specific services by port
+ lsof -ti:8000 | xargs kill -9 2>/dev/null || true # Backend
+ lsof -ti:5173 | xargs kill -9 2>/dev/null || true # Frontend
+ lsof -ti:9876 | xargs kill -9 2>/dev/null || true # Rerun (if spawned)
+
+ echo -e "${GREEN}✓ Services stopped${NC}"
+ exit 0
+}
+
+# Register cleanup on script exit
+trap cleanup EXIT INT TERM
+
+# Check if required commands exist
+command -v rerun >/dev/null 2>&1 || {
+ echo -e "${RED}✗ Error: 'rerun' not found. Please install: pip install rerun-sdk${NC}"
+ exit 1
+}
+
+command -v python >/dev/null 2>&1 || {
+ echo -e "${RED}✗ Error: 'python' not found${NC}"
+ exit 1
+}
+
+command -v npm >/dev/null 2>&1 || {
+ echo -e "${RED}✗ Error: 'npm' not found${NC}"
+ exit 1
+}
+
+# Check if node_modules exists
+if [ ! -d "node_modules" ]; then
+ echo -e "${YELLOW}⚠ node_modules not found. Running npm install...${NC}"
+ npm install
+ echo -e "${GREEN}✓ Dependencies installed${NC}"
+ echo ""
+fi
+
+echo -e "${GREEN}Starting services...${NC}"
+echo ""
+
+# 1. Start FastAPI backend (Rerun will start when recording begins)
+echo -e "${BLUE}[1/2]${NC} Starting FastAPI backend on port 8000..."
+cd "$SCRIPT_DIR"
+python web_record_server.py > /tmp/openarms_backend.log 2>&1 &
+BACKEND_PID=$!
+sleep 3
+
+if ps -p $BACKEND_PID > /dev/null; then
+ echo -e "${GREEN}✓ Backend started${NC} (PID: $BACKEND_PID)"
+ echo -e " URL: ${BLUE}http://localhost:8000${NC}"
+else
+ echo -e "${RED}✗ Failed to start backend${NC}"
+ echo -e "${YELLOW}Check logs: tail -f /tmp/openarms_backend.log${NC}"
+ exit 1
+fi
+echo ""
+
+# 2. Start React frontend
+echo -e "${BLUE}[2/2]${NC} Starting React frontend on port 5173..."
+cd "$SCRIPT_DIR"
+npm run dev > /tmp/openarms_frontend.log 2>&1 &
+FRONTEND_PID=$!
+sleep 3
+
+if ps -p $FRONTEND_PID > /dev/null; then
+ echo -e "${GREEN}✓ Frontend started${NC} (PID: $FRONTEND_PID)"
+ echo -e " URL: ${BLUE}http://localhost:5173${NC}"
+else
+ echo -e "${RED}✗ Failed to start frontend${NC}"
+ echo -e "${YELLOW}Check logs: tail -f /tmp/openarms_frontend.log${NC}"
+ exit 1
+fi
+echo ""
+
+# Display status
+echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"
+echo -e "${GREEN}║ All services running! 🚀 ║${NC}"
+echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"
+echo ""
+echo -e "🔧 ${BLUE}Backend:${NC} http://localhost:8000"
+echo -e "🌐 ${BLUE}Frontend:${NC} http://localhost:5173"
+echo -e "📊 ${BLUE}Rerun:${NC} Will spawn automatically when recording starts"
+echo ""
+echo -e "${YELLOW}Open your browser to:${NC} ${BLUE}http://localhost:5173${NC}"
+echo ""
+echo -e "${YELLOW}Logs:${NC}"
+echo -e " • Backend: tail -f /tmp/openarms_backend.log"
+echo -e " • Frontend: tail -f /tmp/openarms_frontend.log"
+echo ""
+echo -e "${RED}Press Ctrl+C to stop all services${NC}"
+echo ""
+
+# Keep script running and wait for any service to exit
+wait
+
diff --git a/examples/openarms_web_interface/main.jsx b/examples/openarms_web_interface/main.jsx
new file mode 100644
index 000000000..4f9c07226
--- /dev/null
+++ b/examples/openarms_web_interface/main.jsx
@@ -0,0 +1,7 @@
+import { createRoot } from 'react-dom/client'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(
+
+)
+
diff --git a/examples/openarms_web_interface/package-lock.json b/examples/openarms_web_interface/package-lock.json
new file mode 100644
index 000000000..10df79f72
--- /dev/null
+++ b/examples/openarms_web_interface/package-lock.json
@@ -0,0 +1,1955 @@
+{
+ "name": "openarms-web-interface",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openarms-web-interface",
+ "version": "0.0.0",
+ "dependencies": {
+ "@rerun-io/web-viewer-react": "^0.20.3",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^6.0.1",
+ "vite-plugin-top-level-await": "^1.4.4",
+ "vite-plugin-wasm": "^3.3.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
+ "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+ "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.28.5"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+ "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.5",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rerun-io/web-viewer": {
+ "version": "0.20.3",
+ "resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.20.3.tgz",
+ "integrity": "sha512-vGmpgQGXFsNwsTzHNJCkiRi1s3mFe9gKHRKVjj3HZdmFGNTpg86V1tdVCe8+QYx1Bgbtb6Pd3OceLS7rKWoiVA=="
+ },
+ "node_modules/@rerun-io/web-viewer-react": {
+ "version": "0.20.3",
+ "resolved": "https://registry.npmjs.org/@rerun-io/web-viewer-react/-/web-viewer-react-0.20.3.tgz",
+ "integrity": "sha512-RlkY+dkaNGPrwIXnoAW6tGqtDRLDYhNN5NVcBSVPBpQ7Del5GVA35/VW9tfJqjLNTGThZSYzG6xDwjtMaC/Ftg==",
+ "dependencies": {
+ "@rerun-io/web-viewer": "0.20.3",
+ "@types/react": "^18.2.33",
+ "react": "^18.2.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true
+ },
+ "node_modules/@rollup/plugin-virtual": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz",
+ "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
+ "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
+ "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
+ "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
+ "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
+ "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
+ "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
+ "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
+ "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
+ "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
+ "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
+ "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
+ "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
+ "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
+ "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
+ "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
+ "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
+ "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
+ "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
+ "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
+ "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
+ "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
+ "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@swc/core": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.14.0.tgz",
+ "integrity": "sha512-oExhY90bes5pDTVrei0xlMVosTxwd/NMafIpqsC4dMbRYZ5KB981l/CX8tMnGsagTplj/RcG9BeRYmV6/J5m3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "@swc/types": "^0.1.25"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/swc"
+ },
+ "optionalDependencies": {
+ "@swc/core-darwin-arm64": "1.14.0",
+ "@swc/core-darwin-x64": "1.14.0",
+ "@swc/core-linux-arm-gnueabihf": "1.14.0",
+ "@swc/core-linux-arm64-gnu": "1.14.0",
+ "@swc/core-linux-arm64-musl": "1.14.0",
+ "@swc/core-linux-x64-gnu": "1.14.0",
+ "@swc/core-linux-x64-musl": "1.14.0",
+ "@swc/core-win32-arm64-msvc": "1.14.0",
+ "@swc/core-win32-ia32-msvc": "1.14.0",
+ "@swc/core-win32-x64-msvc": "1.14.0"
+ },
+ "peerDependencies": {
+ "@swc/helpers": ">=0.5.17"
+ },
+ "peerDependenciesMeta": {
+ "@swc/helpers": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@swc/core-darwin-arm64": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.14.0.tgz",
+ "integrity": "sha512-uHPC8rlCt04nvYNczWzKVdgnRhxCa3ndKTBBbBpResOZsRmiwRAvByIGh599j+Oo6Z5eyTPrgY+XfJzVmXnN7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-darwin-x64": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.14.0.tgz",
+ "integrity": "sha512-2SHrlpl68vtePRknv9shvM9YKKg7B9T13tcTg9aFCwR318QTYo+FzsKGmQSv9ox/Ua0Q2/5y2BNjieffJoo4nA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm-gnueabihf": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.14.0.tgz",
+ "integrity": "sha512-SMH8zn01dxt809svetnxpeg/jWdpi6dqHKO3Eb11u4OzU2PK7I5uKS6gf2hx5LlTbcJMFKULZiVwjlQLe8eqtg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-gnu": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.14.0.tgz",
+ "integrity": "sha512-q2JRu2D8LVqGeHkmpVCljVNltG0tB4o4eYg+dElFwCS8l2Mnt9qurMCxIeo9mgoqz0ax+k7jWtIRHktnVCbjvQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-arm64-musl": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.14.0.tgz",
+ "integrity": "sha512-uofpVoPCEUjYIv454ZEZ3sLgMD17nIwlz2z7bsn7rl301Kt/01umFA7MscUovFfAK2IRGck6XB+uulMu6aFhKQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-gnu": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.14.0.tgz",
+ "integrity": "sha512-quTTx1Olm05fBfv66DEBuOsOgqdypnZ/1Bh3yGXWY7ANLFeeRpCDZpljD9BSjdsNdPOlwJmEUZXMHtGm3v1TZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-linux-x64-musl": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.14.0.tgz",
+ "integrity": "sha512-caaNAu+aIqT8seLtCf08i8C3/UC5ttQujUjejhMcuS1/LoCKtNiUs4VekJd2UGt+pyuuSrQ6dKl8CbCfWvWeXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-arm64-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.14.0.tgz",
+ "integrity": "sha512-EeW3jFlT3YNckJ6V/JnTfGcX7UHGyh6/AiCPopZ1HNaGiXVCKHPpVQZicmtyr/UpqxCXLrTgjHOvyMke7YN26A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-ia32-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.14.0.tgz",
+ "integrity": "sha512-dPai3KUIcihV5hfoO4QNQF5HAaw8+2bT7dvi8E5zLtecW2SfL3mUZipzampXq5FHll0RSCLzlrXnSx+dBRZIIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/core-win32-x64-msvc": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.14.0.tgz",
+ "integrity": "sha512-nm+JajGrTqUA6sEHdghDlHMNfH1WKSiuvljhdmBACW4ta4LC3gKurX2qZuiBARvPkephW9V/i5S8QPY1PzFEqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "dev": true
+ },
+ "node_modules/@swc/types": {
+ "version": "0.1.25",
+ "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
+ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
+ "dev": true,
+ "dependencies": {
+ "@swc/counter": "^0.1.3"
+ }
+ },
+ "node_modules/@swc/wasm": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.14.0.tgz",
+ "integrity": "sha512-eUUA3jEwFzoMh6mUksvaqX3wK+FxKFFFapBic+sQigxD5NytXM+PFARbv17BECKV/XcCCROHFV9+e73lYOsV3A==",
+ "dev": true
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.26",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
+ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.8.23",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz",
+ "integrity": "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==",
+ "dev": true,
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.27.0",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
+ "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "baseline-browser-mapping": "^2.8.19",
+ "caniuse-lite": "^1.0.30001751",
+ "electron-to-chromium": "^1.5.238",
+ "node-releases": "^2.0.26",
+ "update-browserslist-db": "^1.1.4"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001753",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz",
+ "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.244",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz",
+ "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.52.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
+ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.52.5",
+ "@rollup/rollup-android-arm64": "4.52.5",
+ "@rollup/rollup-darwin-arm64": "4.52.5",
+ "@rollup/rollup-darwin-x64": "4.52.5",
+ "@rollup/rollup-freebsd-arm64": "4.52.5",
+ "@rollup/rollup-freebsd-x64": "4.52.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.52.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.52.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.52.5",
+ "@rollup/rollup-linux-arm64-musl": "4.52.5",
+ "@rollup/rollup-linux-loong64-gnu": "4.52.5",
+ "@rollup/rollup-linux-ppc64-gnu": "4.52.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.52.5",
+ "@rollup/rollup-linux-riscv64-musl": "4.52.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.52.5",
+ "@rollup/rollup-linux-x64-gnu": "4.52.5",
+ "@rollup/rollup-linux-x64-musl": "4.52.5",
+ "@rollup/rollup-openharmony-arm64": "4.52.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.52.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.52.5",
+ "@rollup/rollup-win32-x64-gnu": "4.52.5",
+ "@rollup/rollup-win32-x64-msvc": "4.52.5",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
+ "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-plugin-top-level-await": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz",
+ "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==",
+ "dev": true,
+ "dependencies": {
+ "@rollup/plugin-virtual": "^3.0.2",
+ "@swc/core": "^1.12.14",
+ "@swc/wasm": "^1.12.14",
+ "uuid": "10.0.0"
+ },
+ "peerDependencies": {
+ "vite": ">=2.8"
+ }
+ },
+ "node_modules/vite-plugin-wasm": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz",
+ "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==",
+ "dev": true,
+ "peerDependencies": {
+ "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+}
diff --git a/examples/openarms_web_interface/package.json b/examples/openarms_web_interface/package.json
new file mode 100644
index 000000000..f95cb8959
--- /dev/null
+++ b/examples/openarms_web_interface/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "openarms-web-interface",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.12",
+ "@types/react-dom": "^18.3.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "vite": "^6.0.1"
+ }
+}
diff --git a/examples/openarms_web_interface/vite.config.js b/examples/openarms_web_interface/vite.config.js
new file mode 100644
index 000000000..a7a1be6c0
--- /dev/null
+++ b/examples/openarms_web_interface/vite.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ strictPort: false,
+ host: true,
+ open: false
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true
+ }
+})
diff --git a/examples/openarms_web_interface/web_record_server.py b/examples/openarms_web_interface/web_record_server.py
new file mode 100644
index 000000000..d741ef460
--- /dev/null
+++ b/examples/openarms_web_interface/web_record_server.py
@@ -0,0 +1,867 @@
+"""
+OpenArms Web Recording Server
+
+FastAPI backend for recording OpenArms datasets with gravity compensation.
+Provides camera streaming, robot control, and automatic HuggingFace upload.
+"""
+
+import asyncio
+import platform
+import re
+import shutil
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, Any
+import threading
+
+import cv2
+import numpy as np
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+# LeRobot imports
+from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig
+from lerobot.datasets.lerobot_dataset import LeRobotDataset
+from lerobot.datasets.utils import build_dataset_frame, hw_to_dataset_features
+from lerobot.robots.openarms.config_openarms_follower import OpenArmsFollowerConfig
+from lerobot.robots.openarms.openarms_follower import OpenArmsFollower
+from lerobot.teleoperators.openarms.config_openarms_leader import OpenArmsLeaderConfig
+from lerobot.teleoperators.openarms.openarms_leader import OpenArmsLeader
+
+app = FastAPI(title="OpenArms Recording Server")
+
+# Enable CORS
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Global state
+recording_state = {
+ "is_recording": False,
+ "is_initializing": False,
+ "is_encoding": False,
+ "is_uploading": False,
+ "robots_ready": False,
+ "start_time": None,
+ "task": "",
+ "episode_count": 0,
+ "error": None,
+ "status_message": "Ready",
+ "upload_status": None,
+ "current_fps": 0.0,
+ "config": {
+ "leader_left": "can0",
+ "leader_right": "can1",
+ "follower_left": "can2",
+ "follower_right": "can3",
+ "left_wrist": "/dev/video0",
+ "right_wrist": "/dev/video1",
+ "base": "/dev/video4",
+ }
+}
+
+# Global robot instances
+robot_instances = {
+ "follower": None,
+ "leader": None,
+ "dataset": None,
+ "dataset_features": None,
+}
+
+# Camera frames from recording loop (for streaming during recording)
+camera_frames = {
+ "left_wrist": None,
+ "right_wrist": None,
+ "base": None,
+}
+camera_frames_lock = threading.Lock()
+
+# Recording control
+recording_thread = None
+stop_recording_flag = threading.Event()
+FPS = 30
+FRICTION_SCALE = 1.0
+
+
+class RecordingConfig(BaseModel):
+ task: str
+ leader_left: str
+ leader_right: str
+ follower_left: str
+ follower_right: str
+ left_wrist: str
+ right_wrist: str
+ base: str
+
+
+class RobotSetupConfig(BaseModel):
+ """Configuration for robot setup (no task required)."""
+ leader_left: str
+ leader_right: str
+ follower_left: str
+ follower_right: str
+ left_wrist: str
+ right_wrist: str
+ base: str
+
+
+class CounterUpdate(BaseModel):
+ value: int
+
+
+def discover_cameras_sync():
+ """Discover available OpenCV cameras."""
+ try:
+ from lerobot.cameras.opencv.camera_opencv import OpenCVCamera
+ cameras = OpenCVCamera.find_cameras()
+ print(f"[Cameras] Found {len(cameras)} camera(s)")
+ return cameras
+ except Exception as e:
+ print(f"[Cameras] Error: {e}")
+ return []
+
+
+@app.get("/api/cameras/discover")
+async def discover_cameras():
+ """Discover available cameras."""
+ cameras = discover_cameras_sync()
+ return {"cameras": cameras}
+
+
+@app.get("/api/can/interfaces")
+async def get_can_interfaces():
+ """Get available CAN interfaces."""
+ return {"interfaces": ["can0", "can1", "can2", "can3"]}
+
+
+def initialize_robots_only(config: RobotSetupConfig):
+ """Initialize robots only (no dataset) for pre-setup."""
+ global robot_instances, recording_state
+
+ print(f"[Setup] Initializing robots...")
+
+ # Update status: Configuring cameras
+ recording_state["status_message"] = "Configuring cameras..."
+ camera_config = {
+ "left_wrist": OpenCVCameraConfig(index_or_path=config.left_wrist, width=640, height=480, fps=FPS),
+ "right_wrist": OpenCVCameraConfig(index_or_path=config.right_wrist, width=640, height=480, fps=FPS),
+ "base": OpenCVCameraConfig(index_or_path=config.base, width=640, height=480, fps=FPS),
+ }
+
+ # Configure follower robot with cameras
+ recording_state["status_message"] = "Configuring follower robot..."
+ follower_config = OpenArmsFollowerConfig(
+ port_left=config.follower_left,
+ port_right=config.follower_right,
+ can_interface="socketcan",
+ id="openarms_follower",
+ disable_torque_on_disconnect=True,
+ max_relative_target=10.0,
+ cameras=camera_config,
+ )
+
+ # Configure leader teleoperator
+ recording_state["status_message"] = "Configuring leader teleoperator..."
+ leader_config = OpenArmsLeaderConfig(
+ port_left=config.leader_left,
+ port_right=config.leader_right,
+ can_interface="socketcan",
+ id="openarms_leader",
+ manual_control=False,
+ )
+
+ # Initialize and connect
+ recording_state["status_message"] = "Connecting to follower robot..."
+ follower = OpenArmsFollower(follower_config)
+ follower.connect(calibrate=False) # Skip calibration in web mode
+
+ recording_state["status_message"] = "Connecting to leader teleoperator..."
+ leader = OpenArmsLeader(leader_config)
+ leader.connect(calibrate=False)
+
+ # Verify URDF is loaded
+ recording_state["status_message"] = "Loading URDF model for gravity compensation..."
+ if leader.pin_robot is None:
+ raise RuntimeError("URDF model not loaded on leader. Gravity compensation not available.")
+
+ # Enable gravity compensation
+ recording_state["status_message"] = "Enabling gravity compensation..."
+ leader.bus_right.enable_torque()
+ leader.bus_left.enable_torque()
+ time.sleep(0.1)
+
+ robot_instances["follower"] = follower
+ robot_instances["leader"] = leader
+
+
+def initialize_robot_systems(config: RecordingConfig):
+ """Initialize robot, leader, and dataset."""
+ global robot_instances, recording_state
+
+ # Check if robots are already initialized
+ if robot_instances.get("follower") and robot_instances.get("leader"):
+ recording_state["status_message"] = "Using existing robots..."
+ follower = robot_instances["follower"]
+ leader = robot_instances["leader"]
+ print(f"[Initialize] Reusing existing robots")
+ else:
+ # Full initialization required
+ # Update status: Configuring cameras
+ recording_state["status_message"] = "Configuring cameras..."
+ camera_config = {
+ "left_wrist": OpenCVCameraConfig(index_or_path=config.left_wrist, width=640, height=480, fps=FPS),
+ "right_wrist": OpenCVCameraConfig(index_or_path=config.right_wrist, width=640, height=480, fps=FPS),
+ "base": OpenCVCameraConfig(index_or_path=config.base, width=640, height=480, fps=FPS),
+ }
+
+ # Configure follower robot with cameras
+ recording_state["status_message"] = "Configuring follower robot..."
+ follower_config = OpenArmsFollowerConfig(
+ port_left=config.follower_left,
+ port_right=config.follower_right,
+ can_interface="socketcan",
+ id="openarms_follower",
+ disable_torque_on_disconnect=True,
+ max_relative_target=10.0,
+ cameras=camera_config,
+ )
+
+ # Configure leader teleoperator
+ recording_state["status_message"] = "Configuring leader teleoperator..."
+ leader_config = OpenArmsLeaderConfig(
+ port_left=config.leader_left,
+ port_right=config.leader_right,
+ can_interface="socketcan",
+ id="openarms_leader",
+ manual_control=False,
+ )
+
+ # Initialize and connect
+ recording_state["status_message"] = "Connecting to follower robot..."
+ follower = OpenArmsFollower(follower_config)
+ follower.connect(calibrate=False) # Skip calibration in web mode
+
+ recording_state["status_message"] = "Connecting to leader teleoperator..."
+ leader = OpenArmsLeader(leader_config)
+ leader.connect(calibrate=False)
+
+ # Verify URDF is loaded
+ recording_state["status_message"] = "Loading URDF model for gravity compensation..."
+ if leader.pin_robot is None:
+ raise RuntimeError("URDF model not loaded on leader. Gravity compensation not available.")
+
+ # Enable gravity compensation
+ recording_state["status_message"] = "Enabling gravity compensation..."
+ leader.bus_right.enable_torque()
+ leader.bus_left.enable_torque()
+ time.sleep(0.1)
+
+ # Configure dataset features
+ recording_state["status_message"] = "Configuring dataset features..."
+ action_features_hw = {}
+ for key, value in follower.action_features.items():
+ if key.endswith(".pos"):
+ action_features_hw[key] = value
+
+ action_features = hw_to_dataset_features(action_features_hw, "action")
+ obs_features = hw_to_dataset_features(follower.observation_features, "observation")
+ dataset_features = {**action_features, **obs_features}
+
+ # Create dataset
+ recording_state["status_message"] = "Creating dataset..."
+ now = datetime.now()
+ date_str = now.strftime("%Y-%m-%d")
+ time_str = now.strftime("%H-%M")
+
+ # Validate and sanitize task name
+ task_raw = config.task.strip()
+ if not task_raw:
+ raise ValueError("Task description cannot be empty")
+
+ # Replace spaces with hyphens and convert to lowercase
+ task = task_raw.replace(" ", "-").lower()
+
+ # Remove any leading/trailing hyphens or dots
+ task = task.strip("-.")
+
+ # Ensure task name is valid (alphanumeric, hyphens, underscores, dots only)
+ task = re.sub(r'[^a-z0-9\-_.]', '', task)
+
+ if not task:
+ raise ValueError("Task description must contain alphanumeric characters")
+
+ dataset_name = f"{task}-{date_str}-{time_str}"
+ repo_id = f"lerobot-data-collection/{dataset_name}"
+
+ print(f"[Initialize] Sanitized task: '{config.task}' -> '{task}'")
+ print(f"[Initialize] Dataset name: {dataset_name}")
+ print(f"[Initialize] Repo ID: {repo_id}")
+
+ # Remove existing dataset if it exists
+ dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id
+ if dataset_path.exists():
+ recording_state["status_message"] = "Removing existing dataset..."
+ shutil.rmtree(dataset_path)
+
+ try:
+ dataset = LeRobotDataset.create(
+ repo_id=repo_id,
+ fps=FPS,
+ features=dataset_features,
+ robot_type=follower.name,
+ use_videos=True,
+ image_writer_processes=0, # Use threads only (faster for single process)
+ image_writer_threads=8, # More threads for 3 cameras (2-3 per camera)
+ )
+ print(f"[Dataset] Created: {repo_id}")
+ except Exception as e:
+ print(f"[Dataset] Error: {e}")
+ raise RuntimeError(f"Failed to create dataset: {e}")
+
+ robot_instances["follower"] = follower
+ robot_instances["leader"] = leader
+ robot_instances["dataset"] = dataset
+ robot_instances["dataset_features"] = dataset_features
+ robot_instances["repo_id"] = repo_id
+
+ if robot_instances.get("dataset") is None:
+ raise RuntimeError("Dataset was not stored!")
+
+ print(f"[Initialize] All systems ready")
+
+ recording_state["status_message"] = "Recording..."
+ # Keep robots_ready = True so we can reuse them for next recording
+ # recording_state["robots_ready"] = False # Don't mark as consumed anymore
+
+ return follower, leader, dataset, dataset_features
+
+
+def record_loop_with_compensation():
+ """Main recording loop with compensation."""
+ global recording_state, stop_recording_flag
+
+ follower = robot_instances.get("follower")
+ leader = robot_instances.get("leader")
+ dataset = robot_instances.get("dataset")
+ dataset_features = robot_instances.get("dataset_features")
+ task = recording_state.get("task", "")
+
+ if follower is None or leader is None or dataset is None:
+ recording_state["error"] = "Robot or dataset not initialized"
+ print(f"[Recording] Error: Missing components")
+ return
+
+ print(f"[Recording] Starting recording loop...")
+
+ dt = 1 / FPS
+ episode_start_time = time.perf_counter()
+ frame_count = 0
+ last_fps_update = episode_start_time
+ fps_frame_count = 0
+
+ # All joints (both arms)
+ all_joints = []
+ for motor in leader.bus_right.motors:
+ all_joints.append(f"right_{motor}")
+ for motor in leader.bus_left.motors:
+ all_joints.append(f"left_{motor}")
+
+ try:
+ while not stop_recording_flag.is_set():
+ loop_start = time.perf_counter()
+ elapsed = loop_start - episode_start_time
+
+ # Calculate actual FPS every second
+ fps_frame_count += 1
+ if elapsed - (last_fps_update - episode_start_time) >= 1.0:
+ actual_fps = fps_frame_count / (elapsed - (last_fps_update - episode_start_time))
+ recording_state["current_fps"] = round(actual_fps, 1)
+ fps_frame_count = 0
+ last_fps_update = loop_start
+
+ # Get leader state
+ leader_action = leader.get_action()
+
+ # Extract positions and velocities
+ leader_positions_deg = {}
+ leader_velocities_deg_per_sec = {}
+
+ for motor in leader.bus_right.motors:
+ pos_key = f"right_{motor}.pos"
+ vel_key = f"right_{motor}.vel"
+ if pos_key in leader_action:
+ leader_positions_deg[f"right_{motor}"] = leader_action[pos_key]
+ if vel_key in leader_action:
+ leader_velocities_deg_per_sec[f"right_{motor}"] = leader_action[vel_key]
+
+ for motor in leader.bus_left.motors:
+ pos_key = f"left_{motor}.pos"
+ vel_key = f"left_{motor}.vel"
+ if pos_key in leader_action:
+ leader_positions_deg[f"left_{motor}"] = leader_action[pos_key]
+ if vel_key in leader_action:
+ leader_velocities_deg_per_sec[f"left_{motor}"] = leader_action[vel_key]
+
+ # Calculate gravity and friction torques
+ leader_positions_rad = {k: np.deg2rad(v) for k, v in leader_positions_deg.items()}
+ leader_gravity_torques_nm = leader._gravity_from_q(leader_positions_rad)
+
+ leader_velocities_rad_per_sec = {k: np.deg2rad(v) for k, v in leader_velocities_deg_per_sec.items()}
+ leader_friction_torques_nm = leader._friction_from_velocity(
+ leader_velocities_rad_per_sec,
+ friction_scale=FRICTION_SCALE
+ )
+
+ # Combine torques
+ leader_total_torques_nm = {}
+ for motor_name in leader_gravity_torques_nm:
+ gravity = leader_gravity_torques_nm.get(motor_name, 0.0)
+ friction = leader_friction_torques_nm.get(motor_name, 0.0)
+ leader_total_torques_nm[motor_name] = gravity + friction
+
+ # Apply compensation to RIGHT arm
+ for motor in leader.bus_right.motors:
+ full_name = f"right_{motor}"
+ position = leader_positions_deg.get(full_name, 0.0)
+ torque = leader_total_torques_nm.get(full_name, 0.0)
+ kd = leader.get_damping_kd(motor)
+
+ leader.bus_right._mit_control(
+ motor=motor, kp=0.0, kd=kd,
+ position_degrees=position,
+ velocity_deg_per_sec=0.0,
+ torque=torque,
+ )
+
+ # Apply compensation to LEFT arm
+ for motor in leader.bus_left.motors:
+ full_name = f"left_{motor}"
+ position = leader_positions_deg.get(full_name, 0.0)
+ torque = leader_total_torques_nm.get(full_name, 0.0)
+ kd = leader.get_damping_kd(motor)
+
+ leader.bus_left._mit_control(
+ motor=motor, kp=0.0, kd=kd,
+ position_degrees=position,
+ velocity_deg_per_sec=0.0,
+ torque=torque,
+ )
+
+ # Send positions to follower
+ follower_action = {}
+ for joint in all_joints:
+ pos_key = f"{joint}.pos"
+ if pos_key in leader_action:
+ follower_action[pos_key] = leader_action[pos_key]
+
+ if follower_action:
+ follower.send_action(follower_action)
+
+ # Get observation
+ observation = follower.get_observation()
+
+ # Store camera frames for streaming (no extra camera reads needed!)
+ with camera_frames_lock:
+ for cam_name in ["left_wrist", "right_wrist", "base"]:
+ if cam_name in observation:
+ camera_frames[cam_name] = observation[cam_name].copy()
+
+ # Add to dataset
+ try:
+ obs_frame = build_dataset_frame(dataset_features, observation, prefix="observation")
+ action_frame = build_dataset_frame(dataset_features, follower_action, prefix="action")
+ frame = {**obs_frame, **action_frame}
+ frame["task"] = task
+ dataset.add_frame(frame)
+ frame_count += 1
+
+ # Log progress every 5 seconds (150 frames @ 30 FPS)
+ if frame_count % 150 == 0:
+ print(f"[Recording] {frame_count} frames @ {recording_state['current_fps']} FPS")
+ except Exception as frame_error:
+ print(f"[Recording] Frame error: {frame_error}")
+ # Continue recording even if one frame fails
+
+ # Maintain loop rate - don't wait if behind, just continue
+ loop_duration = time.perf_counter() - loop_start
+ sleep_time = dt - loop_duration
+
+ # Only sleep if we're ahead of schedule (more than 2ms remaining)
+ if sleep_time > 0.002:
+ time.sleep(min(sleep_time, dt * 0.5)) # Never sleep more than half a frame time
+ # If behind, just continue without sleeping - maintain real-time rate
+
+ except Exception as e:
+ recording_state["error"] = str(e)
+ print(f"[Recording] Error: {e}")
+ finally:
+ print(f"[Recording] Stopped. Total frames: {frame_count}")
+ # Get fresh reference to dataset from global state
+ current_dataset = robot_instances.get("dataset")
+ if current_dataset:
+ print(f"[Recording] Dataset exists, checking buffer...")
+ print(f"[Recording] Episode buffer is None: {current_dataset.episode_buffer is None}")
+ print(f"[Recording] Episode buffer value: {current_dataset.episode_buffer}")
+ if current_dataset.episode_buffer:
+ print(f"[Recording] Buffer size: {current_dataset.episode_buffer.get('size', 'NO SIZE KEY')}")
+ else:
+ print(f"[Recording] WARNING: Buffer is None despite {frame_count} frames!")
+ else:
+ print(f"[Recording] WARNING: Dataset is None in robot_instances!")
+ print(f"[Recording] Local dataset variable: {dataset}")
+
+
+@app.post("/api/recording/start")
+async def start_recording(config: RecordingConfig):
+ """Start recording an episode."""
+ global recording_state, recording_thread, stop_recording_flag
+
+ if recording_state["is_recording"] or recording_state["is_initializing"]:
+ raise HTTPException(status_code=400, detail="Already recording or initializing")
+
+ # Check if robots are available (either from setup or previous recording)
+ if not robot_instances.get("follower") or not robot_instances.get("leader"):
+ raise HTTPException(
+ status_code=400,
+ detail="Robots not initialized. Please click 'Setup Robots' first."
+ )
+
+ try:
+ # Update config
+ recording_state["config"] = config.model_dump()
+ recording_state["task"] = config.task
+ recording_state["error"] = None
+ recording_state["is_initializing"] = True
+ recording_state["status_message"] = "Initializing recording..."
+
+ # Initialize robot systems (will reuse pre-initialized robots if available)
+ initialize_robot_systems(config)
+
+ if robot_instances.get("dataset") is None:
+ raise RuntimeError("Dataset not created!")
+
+ # Start recording
+ recording_state["is_initializing"] = False
+ recording_state["is_recording"] = True
+ recording_state["start_time"] = time.time()
+ stop_recording_flag.clear() # Clear the stop flag
+
+ # Start recording in background thread
+ recording_thread = threading.Thread(target=record_loop_with_compensation, daemon=True)
+ recording_thread.start()
+ time.sleep(0.1)
+
+ if not recording_thread.is_alive():
+ raise RuntimeError("Recording thread failed to start")
+
+ print(f"[Start] Recording started for: {config.task}")
+ return {"status": "started", "task": config.task}
+
+ except Exception as e:
+ recording_state["is_recording"] = False
+ recording_state["is_initializing"] = False
+ recording_state["error"] = str(e)
+ recording_state["status_message"] = f"Error: {str(e)}"
+ # Clean up dataset if initialization failed, but keep robots
+ cleanup_robot_systems(keep_robots=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+
+
+@app.post("/api/recording/stop")
+async def stop_recording():
+ """Stop recording, encode, and upload episode."""
+ global recording_state, recording_thread, stop_recording_flag
+
+ if not recording_state["is_recording"]:
+ raise HTTPException(status_code=400, detail="Not recording")
+
+ recording_state["is_recording"] = False
+ recording_state["status_message"] = "Stopping recording..."
+
+ # Stop the recording thread
+ stop_recording_flag.set()
+ if recording_thread:
+ recording_thread.join(timeout=5)
+
+ dataset = robot_instances.get("dataset")
+ dataset_name = robot_instances.get("repo_id", "").split("/")[-1] if robot_instances.get("repo_id") else ""
+
+ print(f"[Stop] Recording stopped")
+ print(f"[Stop] Dataset is None: {dataset is None}")
+ if dataset:
+ print(f"[Stop] Dataset type: {type(dataset)}")
+ print(f"[Stop] Episode buffer is None: {dataset.episode_buffer is None}")
+ print(f"[Stop] Episode buffer value: {dataset.episode_buffer}")
+ if dataset.episode_buffer:
+ print(f"[Stop] Episode buffer keys: {list(dataset.episode_buffer.keys())}")
+ print(f"[Stop] Episode buffer type: {type(dataset.episode_buffer)}")
+ print(f"[Stop] Episode buffer size: {dataset.episode_buffer.get('size', 'NO SIZE KEY')}")
+
+ # Process episode immediately (blocking)
+ try:
+ # Check buffer the same way as reference implementation
+ if dataset is not None and dataset.episode_buffer is not None and dataset.episode_buffer.get("size", 0) > 0:
+ buffer_size = dataset.episode_buffer.get("size", 0)
+ print(f"[Stop] Buffer size: {buffer_size}")
+
+ # Encode videos
+ recording_state["is_encoding"] = True
+ recording_state["is_uploading"] = False # Ensure upload flag is clear
+ recording_state["status_message"] = f"Encoding videos ({buffer_size} frames)..."
+ recording_state["upload_status"] = None # Clear upload status
+ print(f"[Stop] Saving episode...")
+ dataset.save_episode()
+ dataset.finalize()
+ recording_state["is_encoding"] = False
+ recording_state["status_message"] = "Encoding complete, uploading..."
+ print(f"[Stop] Episode saved")
+
+ # Upload to hub
+ recording_state["is_uploading"] = True
+ recording_state["status_message"] = "Uploading to HuggingFace..."
+ recording_state["upload_status"] = "Uploading..."
+ print(f"[Stop] Uploading to hub...")
+ dataset.push_to_hub(private=True)
+
+ recording_state["is_uploading"] = False
+ recording_state["upload_status"] = "✓ Upload successful!"
+ recording_state["status_message"] = "Ready"
+ recording_state["episode_count"] += 1
+ print(f"[Stop] Upload complete. Episode count: {recording_state['episode_count']}")
+ else:
+ recording_state["status_message"] = "No data"
+ recording_state["upload_status"] = "No data"
+ print(f"[Stop] No dataset or buffer (dataset={dataset is not None}, buffer={dataset.episode_buffer is not None if dataset else 'N/A'}, size={dataset.episode_buffer.get('size', 0) if dataset and dataset.episode_buffer else 'N/A'})")
+
+ # Clean up dataset, keep robots
+ cleanup_robot_systems(keep_robots=True)
+
+ except Exception as e:
+ recording_state["is_encoding"] = False
+ recording_state["is_uploading"] = False
+ recording_state["error"] = f"Upload failed: {str(e)}"
+ recording_state["status_message"] = f"Error: {str(e)}"
+ recording_state["upload_status"] = f"✗ Upload failed"
+ cleanup_robot_systems(keep_robots=True)
+ print(f"[Stop] Error: {e}")
+ import traceback
+ traceback.print_exc()
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {
+ "status": "stopped",
+ "dataset_name": dataset_name,
+ "episode_count": recording_state["episode_count"]
+ }
+
+
+def cleanup_robot_systems(keep_robots=False):
+ """Clean up robot systems."""
+ global robot_instances
+
+ try:
+ if not keep_robots:
+ if robot_instances.get("leader"):
+ robot_instances["leader"].bus_right.disable_torque()
+ robot_instances["leader"].bus_left.disable_torque()
+ time.sleep(0.1)
+ robot_instances["leader"].disconnect()
+
+ if robot_instances.get("follower"):
+ robot_instances["follower"].disconnect()
+
+ robot_instances["follower"] = None
+ robot_instances["leader"] = None
+ recording_state["robots_ready"] = False
+ print(f"[Cleanup] Robots disconnected")
+
+ # Always clean up dataset
+ robot_instances["dataset"] = None
+ robot_instances["dataset_features"] = None
+ robot_instances["repo_id"] = None
+
+ except Exception as e:
+ print(f"[Cleanup] Error: {e}")
+
+
+@app.post("/api/robots/setup")
+async def setup_robots(config: RobotSetupConfig):
+ """Pre-initialize robots for faster recording start."""
+ global recording_state, robot_instances
+
+ if recording_state["is_recording"] or recording_state["is_initializing"]:
+ raise HTTPException(status_code=400, detail="Cannot setup robots while recording")
+
+ if recording_state["robots_ready"] and robot_instances.get("follower") and robot_instances.get("leader"):
+ raise HTTPException(status_code=400, detail="Robots already initialized")
+
+ # Clean up any existing robots first
+ if robot_instances.get("follower") or robot_instances.get("leader"):
+ cleanup_robot_systems()
+
+ try:
+ recording_state["error"] = None
+ recording_state["status_message"] = "Setting up robots..."
+
+ # Update config (add empty task for compatibility)
+ config_dict = config.model_dump()
+ config_dict["task"] = "" # Task not needed for setup
+ recording_state["config"] = config_dict
+
+ # Initialize robot systems (without dataset creation)
+ initialize_robots_only(config)
+
+ recording_state["robots_ready"] = True
+ recording_state["status_message"] = "Robots ready for recording"
+
+ return {"status": "ready", "message": "Robots initialized successfully"}
+
+ except Exception as e:
+ recording_state["error"] = str(e)
+ recording_state["status_message"] = f"Setup failed"
+ recording_state["robots_ready"] = False
+ print(f"[Setup] Error: {e}")
+ cleanup_robot_systems()
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/robots/disconnect")
+async def disconnect_robots():
+ """Disconnect pre-initialized robots."""
+ global recording_state
+
+ if recording_state["is_recording"] or recording_state["is_initializing"]:
+ raise HTTPException(status_code=400, detail="Cannot disconnect while recording")
+
+ recording_state["robots_ready"] = False
+ recording_state["status_message"] = "Robots disconnected"
+ cleanup_robot_systems(keep_robots=False) # Actually disconnect robots
+
+ return {"status": "disconnected"}
+
+
+@app.get("/api/status")
+async def get_status():
+ """Get current recording status."""
+ elapsed = 0
+ if recording_state["is_recording"] and recording_state["start_time"]:
+ elapsed = time.time() - recording_state["start_time"]
+
+ return {
+ "is_recording": recording_state["is_recording"],
+ "is_initializing": recording_state["is_initializing"],
+ "is_encoding": recording_state["is_encoding"],
+ "is_uploading": recording_state["is_uploading"],
+ "robots_ready": recording_state["robots_ready"],
+ "elapsed_time": elapsed,
+ "current_fps": recording_state["current_fps"],
+ "task": recording_state["task"],
+ "episode_count": recording_state["episode_count"],
+ "error": recording_state["error"],
+ "status_message": recording_state["status_message"],
+ "upload_status": recording_state["upload_status"],
+ "config": recording_state["config"]
+ }
+
+
+@app.post("/api/counter/reset")
+async def reset_counter():
+ """Reset the episode counter."""
+ recording_state["episode_count"] = 0
+ return {"episode_count": 0}
+
+
+@app.post("/api/counter/set")
+async def set_counter(update: CounterUpdate):
+ """Set the episode counter value."""
+ recording_state["episode_count"] = update.value
+ return {"episode_count": update.value}
+
+
+@app.get("/api/camera/stream/{camera_name}")
+async def stream_camera(camera_name: str):
+ """Stream camera feed from robot.
+
+ During recording: streams frames from the recording loop (no extra camera reads = no contention!)
+ When not recording: streams directly from cameras.
+ """
+ def generate():
+ try:
+ while True:
+ frame = None
+
+ # If recording, use frames from recording loop (zero contention!)
+ if recording_state["is_recording"]:
+ with camera_frames_lock:
+ frame = camera_frames.get(camera_name)
+
+ if frame is not None:
+ # Frame already in RGB from recording loop
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ _, buffer = cv2.imencode('.jpg', frame_rgb, [cv2.IMWRITE_JPEG_QUALITY, 70])
+ yield (b'--frame\r\n'
+ b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
+ time.sleep(0.033) # ~30 FPS (matches recording)
+ else:
+ # Wait a bit if frame not available yet
+ time.sleep(0.01)
+ continue
+ else:
+ # Not recording: stream directly from camera
+ follower = robot_instances.get("follower")
+ if not follower or not follower.cameras:
+ break
+
+ if camera_name not in follower.cameras:
+ break
+
+ camera = follower.cameras[camera_name]
+ frame = camera.async_read(timeout_ms=50)
+
+ if frame is None:
+ time.sleep(0.01)
+ continue
+
+ # Convert BGR to RGB
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ _, buffer = cv2.imencode('.jpg', frame_rgb, [cv2.IMWRITE_JPEG_QUALITY, 60])
+
+ yield (b'--frame\r\n'
+ b'Content-Type: image/jpeg\r\n\r\n' + buffer.tobytes() + b'\r\n')
+
+ time.sleep(0.05) # ~20 FPS when not recording
+
+ except Exception as e:
+ print(f"[Camera] Stream error: {e}")
+
+ return StreamingResponse(
+ generate(),
+ media_type="multipart/x-mixed-replace; boundary=frame",
+ headers={
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ "Access-Control-Allow-Origin": "*"
+ }
+ )
+
+
+@app.on_event("shutdown")
+async def shutdown_event():
+ """Clean up resources on shutdown."""
+ global stop_recording_flag
+
+ stop_recording_flag.set()
+ cleanup_robot_systems()
+
+
+if __name__ == "__main__":
+ import uvicorn
+ uvicorn.run(app, host="0.0.0.0", port=8000)