From 0bd16432bc27c7102f63d3bf28de3847d8b74591 Mon Sep 17 00:00:00 2001 From: croissant Date: Sun, 2 Nov 2025 20:06:49 +0100 Subject: [PATCH] add web interface example --- examples/openarms_web_interface/App.css | 481 ++++ examples/openarms_web_interface/App.jsx | 562 +++++ examples/openarms_web_interface/README.md | 41 + examples/openarms_web_interface/index.html | 12 + examples/openarms_web_interface/launch.sh | 125 ++ examples/openarms_web_interface/main.jsx | 7 + .../openarms_web_interface/package-lock.json | 1955 +++++++++++++++++ examples/openarms_web_interface/package.json | 21 + .../openarms_web_interface/vite.config.js | 17 + .../web_record_server.py | 867 ++++++++ 10 files changed, 4088 insertions(+) create mode 100644 examples/openarms_web_interface/App.css create mode 100644 examples/openarms_web_interface/App.jsx create mode 100644 examples/openarms_web_interface/README.md create mode 100644 examples/openarms_web_interface/index.html create mode 100755 examples/openarms_web_interface/launch.sh create mode 100644 examples/openarms_web_interface/main.jsx create mode 100644 examples/openarms_web_interface/package-lock.json create mode 100644 examples/openarms_web_interface/package.json create mode 100644 examples/openarms_web_interface/vite.config.js create mode 100644 examples/openarms_web_interface/web_record_server.py 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 ( +
+
+

OpenArms Recording

+
+ +
+ {/* 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 + +
+ ) : ( +
+ ⚠️ Robots not initialized - Recording will take ~10 seconds + +
+ )} +
+
+ + {/* CAN Interfaces */} +
+

CAN Interfaces

+
+ + + + + + + +
+
+ + {/* Camera Configuration */} +
+
+

Cameras {availableCameras.length > 0 && `(${availableCameras.length} detected)`}

+ +
+
+ + + + + +
+
+
+ )} +
+ + {/* Control Panel */} +
+

🎬 Recording Control

+ + {/* Status Banner - Always show important statuses */} + {isInitializing && ( +
+
+ {statusMessage} +
+ )} + + {isEncoding && ( +
+
+ 📹 {statusMessage} +
+ )} + + {isUploading && ( +
+
+ ☁️ {statusMessage} +
+ )} + + {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} + /> + +
+ + {/* Recording Status */} + {isRecording && ( +
+
+ + {formatTime(elapsedTime)} @ {currentFps.toFixed(1)} FPS + + +
+ )} +
+ + {/* Episode Counter */} +
+
+
Episodes Recorded
+
{episodeCount}
+ +
+
+
+ + {/* Error Display */} + {error && ( +
+ ⚠️ {error} +
+ )} +
+ + {/* Camera Feeds */} +
+

📹 Camera Views

+ {robotsReady || isRecording || isInitializing ? ( +
+
+

Left Wrist

+ Left Wrist Camera +
+ +
+

Base

+ Base Camera +
+ +
+

Right Wrist

+ Right Wrist Camera +
+
+ ) : ( +
+

📷 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)