blanchon commited on
Commit
3cdf7b9
·
1 Parent(s): dd62981
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +238 -208
  2. docker-build.sh +0 -30
  3. log.txt +0 -1
  4. src/lib/components/3d/Floor.svelte +10 -16
  5. src/lib/components/3d/elements/compute/ComputeGridItem.svelte +4 -8
  6. src/lib/components/3d/elements/compute/GPU.svelte +1 -21
  7. src/lib/components/3d/elements/compute/modal/AISessionConnectionModal.svelte +116 -62
  8. src/lib/components/3d/elements/compute/modal/RobotInputConnectionModal.svelte +80 -51
  9. src/lib/components/3d/elements/compute/modal/RobotOutputConnectionModal.svelte +82 -52
  10. src/lib/components/3d/elements/compute/modal/VideoInputConnectionModal.svelte +38 -36
  11. src/lib/components/3d/elements/compute/status/ComputeBoxUIKit.svelte +19 -23
  12. src/lib/components/3d/elements/compute/status/ComputeConnectionFlowBoxUIKit.svelte +4 -13
  13. src/lib/components/3d/elements/compute/status/ComputeInputBoxUIKit.svelte +6 -17
  14. src/lib/components/3d/elements/compute/status/ComputeOutputBoxUIKit.svelte +8 -20
  15. src/lib/components/3d/elements/compute/status/ComputeStatusBillboard.svelte +6 -8
  16. src/lib/components/3d/elements/compute/status/RobotInputBoxUIKit.svelte +20 -31
  17. src/lib/components/3d/elements/compute/status/RobotOutputBoxUIKit.svelte +0 -7
  18. src/lib/components/3d/elements/compute/status/VideoInputBoxUIKit.svelte +1 -15
  19. src/lib/components/3d/elements/robot/RobotGridItem.svelte +30 -29
  20. src/lib/components/3d/elements/robot/Robots.svelte +16 -13
  21. src/lib/components/3d/elements/robot/URDF/primitives/UrdfJoint.svelte +0 -2
  22. src/lib/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte +0 -6
  23. src/lib/components/3d/elements/robot/URDF/primitives/UrdfThree.svelte +1 -11
  24. src/lib/components/3d/elements/robot/URDF/primitives/UrdfVisual.svelte +0 -4
  25. src/lib/components/3d/elements/robot/URDF/utils/UrdfParser.ts +2 -2
  26. src/lib/components/3d/elements/robot/modal/InputConnectionModal.svelte +331 -278
  27. src/lib/components/3d/elements/robot/modal/ManualControlSheet.svelte +56 -28
  28. src/lib/components/3d/elements/robot/modal/OutputConnectionModal.svelte +283 -233
  29. src/lib/components/3d/elements/robot/status/ConnectionFlowBoxUIkit.svelte +1 -12
  30. src/lib/components/3d/elements/robot/status/InputBoxUIKit.svelte +11 -25
  31. src/lib/components/3d/elements/robot/status/ManualControlBoxUIKit.svelte +17 -22
  32. src/lib/components/3d/elements/robot/status/OutputBoxUIKit.svelte +14 -23
  33. src/lib/components/3d/elements/robot/status/RobotBoxUIKit.svelte +0 -1
  34. src/lib/components/3d/elements/video/Video.svelte +38 -23
  35. src/lib/components/3d/elements/video/VideoGridItem.svelte +2 -8
  36. src/lib/components/3d/elements/video/Videos.svelte +3 -3
  37. src/lib/components/3d/elements/video/modal/VideoInputConnectionModal.svelte +463 -402
  38. src/lib/components/3d/elements/video/modal/VideoOutputConnectionModal.svelte +474 -360
  39. src/lib/components/3d/elements/video/status/InputVideoBoxUIKit.svelte +23 -29
  40. src/lib/components/3d/elements/video/status/OutputVideoBoxUIKit.svelte +19 -25
  41. src/lib/components/3d/elements/video/status/VideoBoxUIKit.svelte +0 -8
  42. src/lib/components/3d/elements/video/status/VideoConnectionFlowBoxUIKit.svelte +0 -1
  43. src/lib/components/3d/misc/Pointcloud.svelte +0 -4
  44. src/lib/components/3d/ui/BaseStatusBox.svelte +5 -9
  45. src/lib/components/3d/ui/StatusArrow.svelte +0 -6
  46. src/lib/components/3d/ui/StatusButton.svelte +0 -6
  47. src/lib/components/3d/ui/StatusContent.svelte +0 -8
  48. src/lib/components/3d/ui/StatusHeader.svelte +2 -14
  49. src/lib/components/3d/ui/StatusIndicator.svelte +4 -14
  50. src/lib/components/3d/utils/Hoverable.old.svelte +0 -148
README.md CHANGED
@@ -1,297 +1,327 @@
1
  ---
2
- title: LeRobot Arena Frontend
 
 
 
 
 
 
 
3
  emoji: 🤖
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
  app_port: 8000
8
  pinned: true
9
- fullWidth: true
10
  license: mit
11
- short_description: A web-based robotics control and simulation platform
12
- tags:
13
- - robotics
14
- - control
15
- - simulation
16
- - svelte
17
- - static
18
- - frontend
19
  ---
20
 
21
- # 🤖 LeRobot Arena
22
 
23
- A web-based robotics control and simulation platform that bridges digital twins and physical robots. Built with Svelte for the frontend and FastAPI for the backend.
24
 
25
- ## 🚀 Simple Deployment Options
26
 
27
- Here are the easiest ways to deploy this Svelte frontend:
 
 
 
28
 
29
- ### 🏆 Option 1: Hugging Face Spaces (Static) - RECOMMENDED ✨
 
 
 
 
 
 
 
 
 
 
30
 
31
- **Automatic deployment** (easiest):
32
- 1. **Fork this repository** to your GitHub account
33
- 2. **Create a new Space** on [Hugging Face Spaces](https://huggingface.co/spaces)
34
- 3. **Connect your GitHub repo** - it will auto-detect the static SDK
35
- 4. **Push to main branch** - auto-builds and deploys!
36
 
37
- The frontmatter is already configured with:
38
- ```yaml
39
- sdk: static
40
- app_build_command: bun install && bun run build
41
- app_file: build/index.html
42
- ```
43
 
44
- **Manual upload**:
45
- 1. Run `bun install && bun run build` locally
46
- 2. Create a Space with "Static HTML" SDK
47
- 3. Upload all files from `build/` folder
48
-
49
- ### 🚀 Option 2: Vercel - One-Click Deploy
50
-
51
- [![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new)
52
-
53
- Settings: Build command `bun run build`, Output directory `build`
54
-
55
- ### 📁 Option 3: Netlify - Drag & Drop
56
-
57
- 1. Build locally: `bun install && bun run build`
58
- 2. Drag `build/` folder to [Netlify](https://netlify.com)
59
-
60
- ### 🆓 Option 4: GitHub Pages
61
-
62
- Add this workflow file (`.github/workflows/deploy.yml`):
63
- ```yaml
64
- name: Deploy to GitHub Pages
65
- on:
66
- push:
67
- branches: [ main ]
68
- jobs:
69
- deploy:
70
- runs-on: ubuntu-latest
71
- steps:
72
- - uses: actions/checkout@v4
73
- - uses: oven-sh/setup-bun@v1
74
- - run: bun install --frozen-lockfile
75
- - run: bun run build
76
- - uses: peaceiris/actions-gh-pages@v3
77
- with:
78
- github_token: ${{ secrets.GITHUB_TOKEN }}
79
- publish_dir: ./build
80
- ```
81
 
82
- ### 🐳 Option 5: Docker (Optional)
83
 
84
- For local development or custom hosting:
85
- ```bash
86
- docker build -t lerobot-arena-frontend .
87
- docker run -p 3000:3000 lerobot-arena-frontend
88
- ```
89
 
90
- The Docker setup uses Bun's simple static server - much simpler than the complex server.js approach!
 
 
 
 
 
91
 
92
- ## 🛠️ Development Setup
93
 
94
- For local development with hot-reload capabilities:
95
 
96
- ### Frontend Development
97
 
98
  ```bash
99
- # Install dependencies
100
- bun install
 
101
 
102
- # Start the development server
103
- bun run dev
104
 
105
- # Or open in browser automatically
106
- bun run dev -- --open
107
  ```
108
 
109
- ### Backend Development
110
 
111
  ```bash
112
- # Navigate to Python backend
113
- cd src-python
114
-
115
- # Install Python dependencies (using uv)
116
- uv sync
117
 
118
- # Or using pip
119
- pip install -e .
 
120
 
121
- # Start the backend server
122
- python start_server.py
123
  ```
124
 
125
- ### Building Standalone Executable
126
 
127
- The backend can be packaged as a standalone executable using box-packager:
128
 
129
- ```bash
130
- # Navigate to Python backend
131
- cd src-python
132
 
133
- # Install box-packager (if not already installed)
134
- uv pip install box-packager
 
 
 
 
 
 
 
 
135
 
136
- # Package the application
137
- box package
138
 
139
- # The executable will be in target/release/lerobot-arena-server
140
- ./target/release/lerobot-arena-server
141
- ```
142
 
143
- Note: Requires [Rust/Cargo](https://rustup.rs/) to be installed for box-packager to work.
144
 
145
- ## 📋 Project Structure
 
 
 
 
146
 
147
- ```
148
- lerobot-arena/
149
- ├── src/ # Svelte frontend source
150
- │ ├── lib/ # Reusable components and utilities
151
- │ ├── routes/ # SvelteKit routes
152
- │ └── app.html # App template
153
- ├── src-python/ # Python backend
154
- │ ├── src/ # Python source code
155
- │ ├── start_server.py # Server entry point
156
- │ ├── target/ # Box-packager build output (excluded from git)
157
- │ └── pyproject.toml # Python dependencies
158
- ├── static/ # Static assets
159
- ├── Dockerfile # Docker configuration
160
- ├── docker-compose.yml # Docker Compose setup
161
- └── package.json # Node.js dependencies
162
- ```
163
 
164
- ## 🐳 Docker Information
165
 
166
- The Docker setup includes:
167
 
168
- - **Multi-stage build**: Optimized for production using Bun and uv
169
- - **Automatic startup**: Both services start together
170
- - **Port mapping**: Backend on 8080, Frontend on 3000 (HF Spaces compatible)
171
- - **Static file serving**: Compiled Svelte app served efficiently
172
- - **User permissions**: Properly configured for Hugging Face Spaces
173
- - **Standalone executable**: Backend packaged with box-packager for faster startup
174
 
175
- For detailed Docker documentation, see [DOCKER_README.md](./DOCKER_README.md).
176
 
177
- ## 🔧 Building for Production
 
 
 
 
 
 
 
178
 
179
- ### Frontend Only
180
 
181
- ```bash
182
- bun run build
183
- ```
184
 
185
- ### Backend Standalone Executable
186
 
187
  ```bash
188
- cd src-python
189
- box package
190
  ```
191
 
192
- ### Complete Docker Build
193
 
194
- ```bash
195
- docker-compose up --build
196
- ```
197
 
198
- ## 🌐 What's Included
199
 
200
- - **Real-time Robot Control**: WebSocket-based communication
201
- - **3D Visualization**: Three.js integration for robot visualization
202
- - **URDF Support**: Load and display robot models
203
- - **Multi-robot Management**: Control multiple robots simultaneously
204
- - **WebSocket API**: Real-time bidirectional communication
205
- - **Standalone Distribution**: Self-contained executable with box-packager
206
 
207
- ## 🚨 Troubleshooting
 
208
 
209
- ### Port Conflicts
210
 
211
- If ports 8080 or 3000 are already in use:
212
 
213
- ```bash
214
- # Check what's using the ports
215
- lsof -i :8080
216
- lsof -i :3000
217
 
218
- # Use different ports
219
- docker run -p 8081:8080 -p 7861:3000 lerobot-arena
220
- ```
 
221
 
222
- ### Container Issues
223
 
224
- ```bash
225
- # View logs
226
- docker-compose logs lerobot-arena
227
 
228
- # Rebuild without cache
229
- docker-compose build --no-cache
230
- docker-compose up
231
- ```
232
 
233
- ### Development Issues
234
 
235
- ```bash
236
- # Clear node modules and reinstall
237
- rm -rf node_modules
238
- bun install
 
 
 
 
 
239
 
240
- # Clear Svelte kit cache
241
- rm -rf .svelte-kit
242
- bun run dev
243
  ```
244
 
245
- ### Box-packager Issues
 
 
 
246
 
247
- ```bash
248
- # Clean build artifacts
249
- cd src-python
250
- box clean
251
 
252
- # Rebuild executable
253
- box package
 
254
 
255
- # Install cargo if missing
256
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
 
 
 
 
 
257
  ```
258
 
259
- ## 🚀 Hugging Face Spaces Deployment
260
 
261
- This project is configured for **Static HTML** deployment on Hugging Face Spaces (much simpler than Docker!):
 
 
 
262
 
263
- **Manual Upload (Easiest):**
264
- 1. Run `bun install && bun run build` locally
265
- 2. Create a new Space with "Static HTML" SDK
266
- 3. Upload all files from `build/` folder
267
- 4. Your app is live!
268
 
269
- **GitHub Integration:**
270
- 1. Fork this repository
271
- 2. Create a Space and connect your GitHub repo
272
- 3. The Static HTML SDK will be auto-detected from the README frontmatter
273
- 4. Push changes to auto-deploy
274
 
275
- No Docker, no complex setup - just static files! 🎉
276
 
277
- ## 📚 Additional Documentation
278
 
279
- - [Docker Setup Guide](./DOCKER_README.md) - Detailed Docker instructions
280
- - [Robot Architecture](./ROBOT_ARCHITECTURE.md) - System architecture overview
281
- - [Robot Instancing Guide](./ROBOT_INSTANCING_README.md) - Multi-robot setup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- ## 🤝 Contributing
284
 
285
- 1. Fork the repository
286
- 2. Create a feature branch
287
- 3. Make your changes
288
- 4. Test with Docker: `docker-compose up --build`
289
- 5. Submit a pull request
290
 
291
- ## 📄 License
 
 
 
 
292
 
293
- This project is licensed under the MIT License.
294
 
295
  ---
296
 
297
- **Built with ❤️ for the robotics community** 🤖
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: RobotHub Arena Frontend
3
+ tags:
4
+ - robotics
5
+ - control
6
+ - simulation
7
+ - svelte
8
+ - frontend
9
+ - realtime
10
  emoji: 🤖
11
  colorFrom: blue
12
  colorTo: purple
13
  sdk: docker
14
  app_port: 8000
15
  pinned: true
 
16
  license: mit
17
+ fullWidth: true
18
+ short_description: Web interface of the RobotHub platform – build, monitor & control robots with AI assistance
 
 
 
 
 
 
19
  ---
20
 
21
+ # 🤖 RobotHub Arena – Frontend
22
 
23
+ RobotHub is an **open-source, end-to-end robotics stack** that combines real-time communication, 3-D visualisation, and modern AI policies to control both simulated and physical robots.
24
 
25
+ **This repository contains the *Frontend*** – a SvelteKit web application that runs completely in the browser (or inside Electron / Tauri). It talks to two backend micro-services that live in their own repositories:
26
 
27
+ 1. **[RobotHub Transport Server](https://github.com/julien-blanchon/RobotHub-TransportServer)**
28
+ – WebSocket / WebRTC switch-board for video streams & robot joint messages.
29
+ 2. **[RobotHub Inference Server](https://github.com/julien-blanchon/RobotHub-InferenceServer)**
30
+ – FastAPI service that loads large language- and vision-based policies (ACT, Pi-0, SmolVLA, …) and turns camera images + state into joint commands.
31
 
32
+ ```text
33
+ ┌────────────────────┐ ┌────────────────────────┐ ┌──────────────────────────┐
34
+ │ RobotHub Frontend │ HTTP │ Transport Server │ WebSocket │ Robot / Camera HW │
35
+ │ (this repo) │ <────► │ (rooms, WS, WebRTC) │ ◄──────────►│ – servo bus, USB… │
36
+ │ │ └────────────────────────┘ └──────────────────────────┘
37
+ │ 3-D scene (Threlte)│
38
+ │ UI / Settings │ ┌────────────────────────┐
39
+ │ Svelte 5 runes │ HTTP │ Inference Server │ HTTP/WS │ GPU (Torch, HF models) │
40
+ └────────────────────┘ <────► │ (FastAPI, PyTorch) │ ◄──────────►└──────────────────────────┘
41
+ └────────────────────────┘
42
+ ```
43
 
44
+ ---
 
 
 
 
45
 
46
+ ## Key Features
 
 
 
 
 
47
 
48
+ **Digital-Twin 3-D Scene** – inspect robots, cameras & AI compute blocks in real-time.
49
+ **Multi-Workspace Collaboration** share a hash URL and others join the *same* WS rooms instantly.
50
+ **Drag-&-Drop Add-ons** spawn robots, cameras or AI models from the toolbar.
51
+ **Transport-Agnostic** control physical hardware over USB, or send/receive via WebRTC rooms.
52
+ • **Model Agnostic** – any policy exposed by the Inference Server can be used (ACT, Diffusion, …).
53
+ **Reactive Core** built with *Svelte 5 runes* – state is automatically pushed into the UI.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
+ ---
56
 
57
+ ## 📂 Repository Layout (short)
 
 
 
 
58
 
59
+ | Path | Purpose |
60
+ |-------------------------------|---------|
61
+ | `src/` | SvelteKit app (routes, components) |
62
+ | `src/lib/elements` | Runtime domain logic (robots, video, compute) |
63
+ | `external/RobotHub-*` | Git sub-modules for the backend services – used for generated clients & tests |
64
+ | `static/` | URDFs, STL meshes, textures, favicon |
65
 
66
+ A more in-depth component overview can be found in `/src/lib/components/**` – every major popup/modal has its own Svelte file.
67
 
68
+ ---
69
 
70
+ ## 🚀 Quick Start (dev)
71
 
72
  ```bash
73
+ # 1. clone with submodules (transport + inference)
74
+ $ git clone --recurse-submodules https://github.com/julien-blanchon/RobotHub-Frontend robothub-frontend
75
+ $ cd robothub-frontend
76
 
77
+ # 2. install deps (uses Bun)
78
+ $ bun install
79
 
80
+ # 3. start dev server (http://localhost:5173)
81
+ $ bun run dev -- --open
82
  ```
83
 
84
+ ### Running the full stack locally
85
 
86
  ```bash
87
+ # 1. start Transport Server (rooms & streaming)
88
+ $ cd external/RobotHub-InferenceServer/external/RobotHub-TransportServer/server
89
+ $ uv run launch_with_ui.py # → http://localhost:8000
 
 
90
 
91
+ # 2. start Inference Server (AI brains)
92
+ $ cd ../../..
93
+ $ python launch_simple.py # → http://localhost:8001
94
 
95
+ # 3. frontend (separate terminal)
96
+ $ bun run dev -- --open # → http://localhost:5173 (hash = workspace-id)
97
  ```
98
 
99
+ The **workspace-id** in the URL hash ties all three services together. Share `http://localhost:5173/#<id>` and a collaborator instantly joins the same set of rooms.
100
 
101
+ ---
102
 
103
+ ## 🛠️ Usage Walk-Through
 
 
104
 
105
+ 1. **Open the web-app** → a fresh *workspace* is created ( left corner shows 🌐 ID).
106
+ 2. Click *Add Robot* → spawns an SO-100 6-DoF arm (URDF).
107
+ 3. Click *Add Sensor → Camera* → creates a virtual camera element.
108
+ 4. Click *Add Model → ACT* → spawns a *Compute* block.
109
+ 5. On the Compute block choose *Create Session* – select model path (`./checkpoints/act_so101_beyond`) and cameras (`front`).
110
+ 6. Connect:
111
+ • *Video Input* – local webcam → `front` room.
112
+ • *Robot Input* – robot → *joint-input* room (producer).
113
+ • *Robot Output* – robot ← AI predictions (consumer).
114
+ 7. Press *Start Inference* – the model will predict the next joint trajectory every few frames. 🎉
115
 
116
+ All modals (`AISessionConnectionModal`, `RobotInputConnectionModal`, …) expose precisely what is happening under the hood: which room ID, whether you are *producer* or *consumer*, and the live status.
 
117
 
118
+ ---
 
 
119
 
120
+ ## 🧩 Package Relations
121
 
122
+ | Package | Role | Artifacts exposed to this repo |
123
+ |---------|------|--------------------------------|
124
+ | **Transport Server** | Low-latency switch-board (WS/WebRTC). Creates *rooms* for video & joint messages. | TypeScript & Python client libraries (imported from sub-module) |
125
+ | **Inference Server** | Loads checkpoints (ACT, Pi-0, …) and manages *sessions*. Each session automatically asks the Transport Server to create dedicated rooms. | Generated TS SDK (`@robothub/inference-server-client`) – auto-called from `RemoteComputeManager` |
126
+ | **Frontend (this repo)** | UI + 3-D scene. Manages *robots*, *videos* & *compute* blocks and connects them to the correct rooms. | – |
127
 
128
+ > Because the two backend repos are included as git sub-modules you can develop & debug the whole trio in one repo clone.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ ---
131
 
132
+ ## 📜 Important Components (frontend)
133
 
134
+ `RemoteComputeManager` wraps the Inference Server REST API.
135
+ `RobotManager` talks to Transport Server and USB drivers.
136
+ `VideoManager` handles local/remote camera streams and WebRTC.
 
 
 
137
 
138
+ Each element is a small class with `$state` fields which Svelte 5 picks up automatically. The modals listed below are *thin* UI shells around those classes:
139
 
140
+ ```
141
+ AISessionConnectionModal – create / start / stop AI sessions
142
+ RobotInputConnectionModal – joint-states → AI
143
+ RobotOutputConnectionModal – AI commands → robot
144
+ VideoInputConnectionModal – camera → AI or screen
145
+ ManualControlSheet – slider control, runs when no consumer connected
146
+ SettingsSheet – configure base URLs of the two servers
147
+ ```
148
 
149
+ ---
150
 
151
+ ## 🐳 Docker
 
 
152
 
153
+ A production-grade image is provided (multi-stage, 24 MB with Bun runtime):
154
 
155
  ```bash
156
+ $ docker build -t robothub-frontend .
157
+ $ docker run -p 8000:8000 robothub-frontend # served by vite-preview
158
  ```
159
 
160
+ See `Dockerfile` for the full build – it also performs `bun test` & `bun run build` for the TS clients inside the sub-modules so that the image is completely self-contained.
161
 
162
+ ---
 
 
163
 
164
+ ## 🧑‍💻 Contributing
165
 
166
+ PRs are welcome! The codebase is organised into **domain managers** (robot / video / compute) and **pure-UI** components. If you add a new feature, create a manager first so that business logic can be unit-tested without DOM.
 
 
 
 
 
167
 
168
+ 1. `bun test` – unit tests.
169
+ 2. `bun run typecheck` – strict TS config.
170
 
171
+ Please run `bun format` before committing – ESLint + Prettier configs are included.
172
 
173
+ ---
174
 
175
+ ## 🙏 Special Thanks
 
 
 
176
 
177
+ Huge gratitude to [Tim Qian](https://github.com/timqian) ([X/Twitter](https://x.com/tim_qian)) and the
178
+ [bambot project](https://bambot.org/) for open-sourcing **feetech.js** the
179
+ delightful js driver that powers our USB communication layer.
180
+ ---
181
 
182
+ ## 📄 License
183
 
184
+ MIT – see `LICENSE` in the root.
 
 
185
 
186
+ ## 🌱 Project Philosophy
 
 
 
187
 
188
+ RobotHub follows a **separation-of-concerns** design:
189
 
190
+ * **Transport Server** is the single source of truth for *real-time* data – video frames, joint values, heart-beats. Every participant (browser, Python script, robot firmware) only needs one WebSocket/WebRTC connection, no matter how many peers join later.
191
+ * **Inference Server** is stateless with regard to connectivity; it spins up / tears down *sessions* that rely on rooms in the Transport Server. This lets heavy AI models live on a GPU box while cameras and robots stay on the edge.
192
+ * **Frontend** stays 100 % in the browser – no secret keys or device drivers required – and simply wires together rooms that already exist.
193
+
194
+ > By decoupling the pipeline we can deploy each piece on separate hardware or even different clouds, swap alternative implementations (e.g. ROS bridge instead of WebRTC) and scale each micro-service independently.
195
+
196
+ ---
197
+
198
+ ## 🛰 Transport Server – Real-Time Router
199
 
200
+ ```
201
+ Browser / Robot ⟷ 🌐 Transport Server ⟷ Other Browser / AI / HW
 
202
  ```
203
 
204
+ * **Creates rooms** – `POST /robotics/workspaces/{ws}/rooms` or `POST /video/workspaces/{ws}/rooms`.
205
+ * **Manages roles** – every WebSocket identifies as *producer* (source) or *consumer* (sink).
206
+ * **Does zero processing** – it only forwards JSON (robotics) or WebRTC SDP/ICE (video).
207
+ * **Health-check** – `GET /api/health` returns a JSON heartbeat.
208
 
209
+ Why useful?
 
 
 
210
 
211
+ * You never expose robot hardware directly to the internet – it only speaks to the Transport Server.
212
+ * Multiple followers can subscribe to the *same* producer without extra bandwidth on the producer side (server fans out messages).
213
+ * Works across NAT thanks to WebRTC TURN support.
214
 
215
+ ## 🏢 Workspaces Lightweight Multi-Tenant Isolation
216
+
217
+ A **workspace** is simply a UUID namespace in the Transport Server. Every room URL starts with:
218
+
219
+ ```
220
+ /robotics/workspaces/{workspace_id}/rooms/{room_id}
221
+ /video/workspaces/{workspace_id}/rooms/{room_id}
222
  ```
223
 
224
+ Why bother?
225
 
226
+ 1. **Privacy / Security** – clients in workspace *A* can neither list nor join rooms from workspace *B*. A workspace id is like a private password that keeps the rooms in the same workspace isolated from each other.
227
+ 2. **Organisation** – keep each class, project or experiment separated without spinning up extra servers.
228
+ 3. **Zero-config sharing** – the Frontend stores the workspace ID in the URL hash (e.g. `/#d742e85d-c9e9-4f7b-…`). Send that link to a teammate and they automatically connect to the *same* namespace – all existing video feeds, robot rooms and AI sessions become visible.
229
+ 4. **Stateless Scale-out** – Transport Server holds no global state; deleting a workspace removes all rooms in one call.
230
 
231
+ Typical lifecycle:
 
 
 
 
232
 
233
+ * **Create** – Frontend generates `crypto.randomUUID()` if the hash is empty. Back-end rooms are lazily created when the first producer/consumer calls the REST API.
234
+ * **Share** click the *#workspace* badge → *Copy URL* (handled by `WorkspaceIdButton.svelte`)
 
 
 
235
 
236
+ > Practical tip: Use one workspace per demo to prevent collisions, then recycle it afterwards.
237
 
238
+ ---
239
 
240
+ ## 🧠 Inference Server Session Lifecycle
241
+
242
+ 1. **Create session**
243
+ `POST /api/sessions` with JSON:
244
+ ```jsonc
245
+ {
246
+ "session_id": "pick_place_demo",
247
+ "policy_path": "./checkpoints/act_so101_beyond",
248
+ "camera_names": ["front", "wrist"],
249
+ "transport_server_url": "http://localhost:8000",
250
+ "workspace_id": "<existing-or-new>" // optional
251
+ }
252
+ ```
253
+ 2. **Receive response**
254
+ ```jsonc
255
+ {
256
+ "workspace_id": "ws-uuid",
257
+ "camera_room_ids": { "front": "room-id-a", "wrist": "room-id-b" },
258
+ "joint_input_room_id": "room-id-c",
259
+ "joint_output_room_id": "room-id-d"
260
+ }
261
+ ```
262
+ 3. **Wire connections**
263
+ * Camera PC joins `front` / `wrist` rooms as **producer** (WebRTC).
264
+ * Robot joins `joint_input_room_id` as **producer** (joint states).
265
+ * Robot (or simulator) joins `joint_output_room_id` as **consumer** (commands).
266
+ 4. **Start inference**
267
+ `POST /api/sessions/{id}/start` – server loads the model and begins publishing commands.
268
+ 5. **Stop / delete** as needed. Stats & health are available via `GET /api/sessions`.
269
+
270
+ The Frontend automates steps 1-4 via the *AI Session* modal – you only click buttons.
271
 
272
+ ---
273
 
274
+ ## 🌐 Hosted Demo End-Points
 
 
 
 
275
 
276
+ | Service | URL | Status |
277
+ |---------|-----|--------|
278
+ | Transport Server | <https://blanchon-robothub-transportserver.hf.space/api> | Public & healthy |
279
+ | Inference Server | <https://blanchon-robothub-inferenceserver.hf.space/api> | `{"status":"healthy"}` |
280
+ | Frontend (read-only preview) | <https://blanchon-robothub-frontend.hf.space> | latest `main` |
281
 
282
+ Point the *Settings Server Configuration* panel to these URLs and you can play without any local backend.
283
 
284
  ---
285
 
286
+ ## 🎯 Main Use-Cases
287
+
288
+ Below are typical connection patterns you can set-up **entirely from the UI**. Each example lists the raw data-flow (→ = producer to consumer/AI) plus a video placeholder you can swap for a screen-capture.
289
+
290
+ ### Direct Tele-Operation (Leader ➜ Follower)
291
+ *Leader PC* `USB` ➜ **Robot A** ➜ `Remote producer` → **Transport room** → `Remote consumer` ➜ **Robot B** (`USB`)
292
+
293
+ > One human moves Robot A, Robot B mirrors the motion in real-time. Works with any number of followers – just add more consumers to the same room.
294
+ >
295
+ > 📺 *demo-teleop-1.mp4*
296
+
297
+ ### Web-UI Manual Control
298
+ **Browser sliders** (`ManualControlSheet`) → `Remote producer` → **Robot (USB)**
299
+
300
+ > No physical master arm needed – drive joints from any device.
301
+ >
302
+ > 📺 *demo-webui.mp4*
303
+
304
+ ### AI Inference Loop
305
+ **Robot (USB)** ➜ `Remote producer` → **joint-input room**
306
+ **Camera PC** ➜ `Video producer` → **camera room(s)**
307
+ **Inference Server** (consumer) → processes → publishes to **joint-output room** → `Remote consumer` ➜ **Robot**
308
+
309
+ > Lets a low-power robot PC stream data while a beefy GPU node does the heavy lifting.
310
+ >
311
+ > 📺 *demo-inference.mp4*
312
+
313
+ ### Hybrid Classroom (Multi-Follower AI)
314
+ *Same as AI Inference Loop* with additional **Robot C, D…** subscribing to `joint_output_room_id` to run the same policy in parallel.
315
+
316
+ > Useful for swarm behaviours or classroom demonstrations.
317
+ >
318
+ > 📺 *demo-classroom.mp4*
319
+
320
+ ### Split Video / Robot Across Machines
321
+ **Laptop A** (near cameras) → streams video → Transport
322
+ **Laptop B** (near robot) → joins joint rooms
323
+ **Browser** anywhere → watches video consumer & sends manual overrides
324
+
325
+ > Ideal when the camera PC stays close to sensors and you want minimal upstream bandwidth.
326
+ >
327
+ > 📺 *demo-splitio.mp4*
docker-build.sh DELETED
@@ -1,30 +0,0 @@
1
- #!/bin/bash
2
-
3
- # Build and run the LeRobot Arena Frontend Docker container
4
-
5
- set -e
6
-
7
- echo "🏗️ Building LeRobot Arena Frontend Docker image..."
8
-
9
- # Build the image
10
- docker build -t lerobot-arena-svelte-frontend .
11
-
12
- echo "✅ Build completed successfully!"
13
-
14
- echo "🚀 Starting the container..."
15
-
16
- # Run the container
17
- docker run -d \
18
- --name lerobot-arena-svelte-frontend \
19
- -p 3000:3000 \
20
- --restart unless-stopped \
21
- lerobot-arena-svelte-frontend
22
-
23
- echo "✅ Container started successfully!"
24
- echo "🌐 Frontend is available at: http://localhost:3000"
25
- echo ""
26
- echo "📋 Useful commands:"
27
- echo " • View logs: docker logs -f lerobot-arena-svelte-frontend"
28
- echo " • Stop: docker stop lerobot-arena-svelte-frontend"
29
- echo " • Remove: docker rm lerobot-arena-svelte-frontend"
30
- echo " • Health check: docker inspect --format='{{.State.Health.Status}}' lerobot-arena-svelte-frontend"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
log.txt DELETED
@@ -1 +0,0 @@
1
- .venv/bin/python: can't open file '/Users/julienblanchon/Git/lerobot-arena/lerobot-arena/src-python-video/src/main.py': [Errno 2] No such file or directory
 
 
src/lib/components/3d/Floor.svelte CHANGED
@@ -1,30 +1,24 @@
1
  <script lang="ts">
2
  import { T } from "@threlte/core";
3
- import { PlaneGeometry } from 'three';
4
- import { Grid } from '@threlte/extras'
5
  import { mode } from "mode-watcher";
6
-
7
  const floorGeometry = new PlaneGeometry(20, 20);
8
  </script>
9
 
10
- <T.Mesh
11
- receiveShadow
12
- position.y={0}
13
- rotation.x={-Math.PI / 2}
14
- frustumCulled={false}
15
- >
16
  <T is={floorGeometry} />
17
- <T.ShadowMaterial
18
- opacity={0.3}
19
  transparent={true}
20
  polygonOffset={true}
21
  polygonOffsetFactor={1}
22
  polygonOffsetUnits={1}
23
  />
24
  </T.Mesh>
25
- <Grid
26
- backgroundColor={mode.current === 'dark' ? "#dadada" : "#e2e8f0"}
27
- cellColor={mode.current === 'dark' ? "#000000" : "#94a3b8"}
28
- selectionColor={mode.current === 'dark' ? "#0000ee" : "#3b82f6"}
29
  />
30
-
 
1
  <script lang="ts">
2
  import { T } from "@threlte/core";
3
+ import { PlaneGeometry } from "three";
4
+ import { Grid } from "@threlte/extras";
5
  import { mode } from "mode-watcher";
6
+
7
  const floorGeometry = new PlaneGeometry(20, 20);
8
  </script>
9
 
10
+ <T.Mesh receiveShadow position.y={0} rotation.x={-Math.PI / 2} frustumCulled={false}>
 
 
 
 
 
11
  <T is={floorGeometry} />
12
+ <T.ShadowMaterial
13
+ opacity={0.3}
14
  transparent={true}
15
  polygonOffset={true}
16
  polygonOffsetFactor={1}
17
  polygonOffsetUnits={1}
18
  />
19
  </T.Mesh>
20
+ <Grid
21
+ backgroundColor={mode.current === "dark" ? "#dadada" : "#e2e8f0"}
22
+ cellColor={mode.current === "dark" ? "#000000" : "#94a3b8"}
23
+ selectionColor={mode.current === "dark" ? "#0000ee" : "#3b82f6"}
24
  />
 
src/lib/components/3d/elements/compute/ComputeGridItem.svelte CHANGED
@@ -12,7 +12,8 @@
12
  onRobotOutputBoxClick: (compute: RemoteCompute) => void;
13
  }
14
 
15
- let { compute, onVideoInputBoxClick, onRobotInputBoxClick, onRobotOutputBoxClick }: Props = $props();
 
16
 
17
  const { onPointerEnter, onPointerLeave, hovering } = useCursor();
18
  interactivity();
@@ -31,21 +32,16 @@
31
  position.z={compute.position.z}
32
  scale={[1, 1, 1]}
33
  >
34
- <T.Group
35
- onpointerenter={onPointerEnter}
36
- onpointerleave={onPointerLeave}
37
- onclick={handleClick}
38
- >
39
  <GPU rotating={$hovering} />
40
  </T.Group>
41
  <T.Group scale={[8, 8, 8]} rotation={[-Math.PI / 2, 0, 0]}>
42
  <ComputeStatusBillboard
43
  {compute}
44
- offset={0.8}
45
  {onVideoInputBoxClick}
46
  {onRobotInputBoxClick}
47
  {onRobotOutputBoxClick}
48
  visible={isToggled}
49
  />
50
  </T.Group>
51
- </T.Group>
 
12
  onRobotOutputBoxClick: (compute: RemoteCompute) => void;
13
  }
14
 
15
+ let { compute, onVideoInputBoxClick, onRobotInputBoxClick, onRobotOutputBoxClick }: Props =
16
+ $props();
17
 
18
  const { onPointerEnter, onPointerLeave, hovering } = useCursor();
19
  interactivity();
 
32
  position.z={compute.position.z}
33
  scale={[1, 1, 1]}
34
  >
35
+ <T.Group onpointerenter={onPointerEnter} onpointerleave={onPointerLeave} onclick={handleClick}>
 
 
 
 
36
  <GPU rotating={$hovering} />
37
  </T.Group>
38
  <T.Group scale={[8, 8, 8]} rotation={[-Math.PI / 2, 0, 0]}>
39
  <ComputeStatusBillboard
40
  {compute}
 
41
  {onVideoInputBoxClick}
42
  {onRobotInputBoxClick}
43
  {onRobotOutputBoxClick}
44
  visible={isToggled}
45
  />
46
  </T.Group>
47
+ </T.Group>
src/lib/components/3d/elements/compute/GPU.svelte CHANGED
@@ -1,13 +1,9 @@
1
  <script lang="ts">
2
- import { useCursor } from '@threlte/extras'
3
  import { T } from "@threlte/core";
4
- import { HTML, type IntersectionEvent } from "@threlte/extras";
5
- import { GLTF, useGltf } from "@threlte/extras";
6
  import Model from "./GPUModel.svelte";
7
  import { Shape, Path, ExtrudeGeometry, BoxGeometry } from "three";
8
  import { onMount } from "svelte";
9
- import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
10
- import { videoManager } from "$lib/elements/video/VideoManager.svelte";
11
 
12
  // Props interface
13
  interface Props {
@@ -114,25 +110,9 @@
114
  {rotation}
115
  {scale}
116
  >
117
- <!-- TV Frame -->
118
- <!-- <T.Mesh geometry={frameGeometry}>
119
- <T.MeshStandardMaterial
120
- color={"#374151"}
121
- metalness={0.05}
122
- roughness={0.4}
123
- envMapIntensity={0.3}
124
- />
125
- </T.Mesh> -->
126
  <T.Group
127
  scale={[1, 1, 1]}
128
  >
129
  <Model fan_rotation={fan_rotation} />
130
  </T.Group>
131
- <!-- <GLTF castShadow receiveShadow gltf={$gltf} position={{ y: 1 }} scale={3} /> -->
132
-
133
- <!-- <T.Group scale={[1,1,1]}>
134
- {#if $gltf}
135
- <T is={$gltf.nodes['Sketchfab_model']} />
136
- {/if}
137
- </T.Group> -->
138
  </T.Group>
 
1
  <script lang="ts">
 
2
  import { T } from "@threlte/core";
3
+ import { useGltf } from "@threlte/extras";
 
4
  import Model from "./GPUModel.svelte";
5
  import { Shape, Path, ExtrudeGeometry, BoxGeometry } from "three";
6
  import { onMount } from "svelte";
 
 
7
 
8
  // Props interface
9
  interface Props {
 
110
  {rotation}
111
  {scale}
112
  >
 
 
 
 
 
 
 
 
 
113
  <T.Group
114
  scale={[1, 1, 1]}
115
  >
116
  <Model fan_rotation={fan_rotation} />
117
  </T.Group>
 
 
 
 
 
 
 
118
  </T.Group>
src/lib/components/3d/elements/compute/modal/AISessionConnectionModal.svelte CHANGED
@@ -21,9 +21,9 @@
21
  let { open = $bindable(), compute, workspaceId }: Props = $props();
22
 
23
  let isConnecting = $state(false);
24
- let sessionId = $state('');
25
- let policyPath = $state('./checkpoints/act_so101_beyond');
26
- let cameraNames = $state('front');
27
  let useProvidedWorkspace = $state(false);
28
 
29
  // Auto-generate session ID when modal opens
@@ -37,15 +37,18 @@
37
  if (!compute) return;
38
 
39
  if (!sessionId.trim() || !policyPath.trim()) {
40
- toast.error('Please fill in all required fields');
41
  return;
42
  }
43
 
44
  isConnecting = true;
45
  try {
46
- const cameras = cameraNames.split(',').map(name => name.trim()).filter(name => name);
 
 
 
47
  if (cameras.length === 0) {
48
- cameras.push('front');
49
  }
50
 
51
  const config: AISessionConfig = {
@@ -64,8 +67,8 @@
64
  toast.error(`Failed to create session: ${result.error}`);
65
  }
66
  } catch (error) {
67
- console.error('Session creation error:', error);
68
- toast.error('Failed to create session');
69
  } finally {
70
  isConnecting = false;
71
  }
@@ -78,13 +81,13 @@
78
  try {
79
  const result = await remoteComputeManager.startSession(compute.id);
80
  if (result.success) {
81
- toast.success('Inference Session started');
82
  } else {
83
  toast.error(`Failed to start session: ${result.error}`);
84
  }
85
  } catch (error) {
86
- console.error('Session start error:', error);
87
- toast.error('Failed to start session');
88
  } finally {
89
  isConnecting = false;
90
  }
@@ -97,13 +100,13 @@
97
  try {
98
  const result = await remoteComputeManager.stopSession(compute.id);
99
  if (result.success) {
100
- toast.success('Inference Session stopped');
101
  } else {
102
  toast.error(`Failed to stop session: ${result.error}`);
103
  }
104
  } catch (error) {
105
- console.error('Session stop error:', error);
106
- toast.error('Failed to stop session');
107
  } finally {
108
  isConnecting = false;
109
  }
@@ -116,13 +119,13 @@
116
  try {
117
  const result = await remoteComputeManager.deleteSession(compute.id);
118
  if (result.success) {
119
- toast.success('Inference Session deleted');
120
  } else {
121
  toast.error(`Failed to delete session: ${result.error}`);
122
  }
123
  } catch (error) {
124
- console.error('Session delete error:', error);
125
- toast.error('Failed to delete session');
126
  } finally {
127
  isConnecting = false;
128
  }
@@ -134,9 +137,11 @@
134
  class="max-h-[80vh] max-w-2xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
135
  >
136
  <Dialog.Header class="pb-3">
137
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
 
 
138
  <span class="icon-[mdi--robot-outline] size-5 text-purple-500 dark:text-purple-400"></span>
139
- AI Compute Session - {compute.name || 'No Compute Selected'}
140
  </Dialog.Title>
141
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
142
  Configure and manage ACT model inference sessions for robot control
@@ -150,70 +155,100 @@
150
  >
151
  <div class="flex items-center gap-2">
152
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
153
- <span class="text-sm font-medium text-purple-700 dark:text-purple-300">Session Status</span>
 
 
154
  </div>
155
  {#if compute.hasSession}
156
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
157
  {compute.statusInfo.statusText}
158
  </Badge>
159
  {:else}
160
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Session</Badge>
 
 
161
  {/if}
162
  </div>
163
 
164
  <!-- Current Session Details -->
165
  {#if compute.hasSession && compute.sessionData}
166
- <Card.Root class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5">
 
 
167
  <Card.Header>
168
- <Card.Title class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200">
 
 
169
  <span class="icon-[mdi--cog] size-4"></span>
170
  Current Session
171
  </Card.Title>
172
  </Card.Header>
173
  <Card.Content>
174
  <div class="space-y-3">
175
- <div class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20">
 
 
176
  <div class="grid grid-cols-2 gap-2 text-xs">
177
  <div>
178
- <span class="text-purple-700 font-medium dark:text-purple-300">Session ID:</span>
179
- <span class="text-purple-800 block dark:text-purple-100">{compute.sessionId}</span>
 
 
 
180
  </div>
181
  <div>
182
- <span class="text-purple-700 font-medium dark:text-purple-300">Status:</span>
183
- <span class="text-purple-800 block dark:text-purple-100">{compute.statusInfo.emoji} {compute.statusInfo.statusText}</span>
 
 
184
  </div>
185
  <div>
186
- <span class="text-purple-700 font-medium dark:text-purple-300">Policy:</span>
187
- <span class="text-purple-800 block dark:text-purple-100">{compute.sessionConfig?.policyPath}</span>
 
 
188
  </div>
189
  <div>
190
- <span class="text-purple-700 font-medium dark:text-purple-300">Cameras:</span>
191
- <span class="text-purple-800 block dark:text-purple-100">{compute.sessionConfig?.cameraNames.join(', ')}</span>
 
 
192
  </div>
193
  </div>
194
  </div>
195
 
196
  <!-- Connection Details -->
197
- <div class="rounded-lg border border-green-300/30 bg-green-100/20 p-3 dark:border-green-500/30 dark:bg-green-900/20">
198
- <div class="text-sm font-medium text-green-700 mb-2 dark:text-green-300">📡 Inference Server Connections</div>
 
 
 
 
199
  <div class="space-y-1 text-xs">
200
  <div>
201
  <span class="text-green-600 dark:text-green-400">Workspace:</span>
202
- <span class="text-green-700 font-mono ml-2 dark:text-green-200">{compute.sessionData.workspace_id}</span>
 
 
203
  </div>
204
  {#each Object.entries(compute.sessionData.camera_room_ids) as [camera, roomId]}
205
  <div>
206
  <span class="text-green-600 dark:text-green-400">📹 {camera}:</span>
207
- <span class="text-green-700 font-mono ml-2 dark:text-green-200">{roomId}</span>
 
208
  </div>
209
  {/each}
210
  <div>
211
  <span class="text-green-600 dark:text-green-400">📥 Joint Input:</span>
212
- <span class="text-green-700 font-mono ml-2 dark:text-green-200">{compute.sessionData.joint_input_room_id}</span>
 
 
213
  </div>
214
  <div>
215
  <span class="text-green-600 dark:text-green-400">📤 Joint Output:</span>
216
- <span class="text-green-700 font-mono ml-2 dark:text-green-200">{compute.sessionData.joint_output_room_id}</span>
 
 
217
  </div>
218
  </div>
219
  </div>
@@ -226,10 +261,10 @@
226
  size="sm"
227
  onclick={handleStartSession}
228
  disabled={isConnecting}
229
- class="bg-green-500 hover:bg-green-600 text-xs disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
230
  >
231
  {#if isConnecting}
232
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
233
  Starting...
234
  {:else}
235
  <span class="icon-[mdi--play] mr-1 size-3"></span>
@@ -246,7 +281,7 @@
246
  class="text-xs disabled:opacity-50"
247
  >
248
  {#if isConnecting}
249
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
250
  Stopping...
251
  {:else}
252
  <span class="icon-[mdi--stop] mr-1 size-3"></span>
@@ -262,7 +297,7 @@
262
  class="text-xs disabled:opacity-50"
263
  >
264
  {#if isConnecting}
265
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
266
  Deleting...
267
  {:else}
268
  <span class="icon-[mdi--delete] mr-1 size-3"></span>
@@ -277,9 +312,13 @@
277
 
278
  <!-- Create New Session -->
279
  {#if !compute.hasSession}
280
- <Card.Root class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5">
 
 
281
  <Card.Header>
282
- <Card.Title class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200">
 
 
283
  <span class="icon-[mdi--plus-circle] size-4"></span>
284
  Create Inference Session
285
  </Card.Title>
@@ -288,47 +327,59 @@
288
  <div class="space-y-4">
289
  <div class="grid grid-cols-2 gap-4">
290
  <div class="space-y-2">
291
- <Label for="sessionId" class="text-purple-700 dark:text-purple-300">Session ID</Label>
 
 
292
  <Input
293
  id="sessionId"
294
  bind:value={sessionId}
295
  placeholder="my-session-01"
296
- class="bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
297
  />
298
  </div>
299
  <div class="space-y-2">
300
- <Label for="policyPath" class="text-purple-700 dark:text-purple-300">Policy Path</Label>
 
 
301
  <Input
302
  id="policyPath"
303
  bind:value={policyPath}
304
  placeholder="./checkpoints/act_so101_beyond"
305
- class="bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
306
  />
307
  </div>
308
  </div>
309
 
310
  <div class="grid grid-cols-2 gap-4">
311
  <div class="space-y-2">
312
- <Label for="cameraNames" class="text-purple-700 dark:text-purple-300">Camera Names</Label>
 
 
313
  <Input
314
  id="cameraNames"
315
  bind:value={cameraNames}
316
  placeholder="front, wrist, overhead"
317
- class="bg-slate-50 border-slate-300 text-slate-900 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
318
  />
319
- <p class="text-xs text-slate-600 dark:text-slate-400">Comma-separated camera names</p>
 
 
320
  </div>
321
  <div class="space-y-2">
322
- <Label for="transportServerUrl" class="text-purple-700 dark:text-purple-300">Transport Server URL</Label>
 
 
323
  <Input
324
  id="transportServerUrl"
325
  value={settings.transportServerUrl}
326
  disabled
327
  placeholder="http://localhost:8000"
328
- class="bg-slate-50 border-slate-300 text-slate-900 opacity-60 cursor-not-allowed dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100"
329
  title="Change this value in the settings panel"
330
  />
331
- <p class="text-xs text-slate-600 dark:text-slate-400">Configure in settings panel</p>
 
 
332
  </div>
333
  </div>
334
 
@@ -339,7 +390,7 @@
339
  bind:checked={useProvidedWorkspace}
340
  class="rounded border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800"
341
  />
342
- <Label for="useWorkspace" class="text-purple-700 text-sm dark:text-purple-300">
343
  Use current workspace ({workspaceId})
344
  </Label>
345
  </div>
@@ -347,8 +398,9 @@
347
  <Alert.Root>
348
  <span class="icon-[mdi--information] size-4"></span>
349
  <Alert.Description>
350
- This will create a new ACT inference session with dedicated rooms for camera inputs,
351
- joint inputs, and joint outputs in the inference server communication system.
 
352
  </Alert.Description>
353
  </Alert.Root>
354
 
@@ -359,7 +411,7 @@
359
  class="w-full bg-purple-500 hover:bg-purple-600 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
360
  >
361
  {#if isConnecting}
362
- <span class="icon-[mdi--loading] animate-spin mr-2 size-4"></span>
363
  Creating Session...
364
  {:else}
365
  <span class="icon-[mdi--rocket-launch] mr-2 size-4"></span>
@@ -372,11 +424,13 @@
372
  {/if}
373
 
374
  <!-- Quick Info -->
375
- <div class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500">
 
 
376
  <span class="icon-[mdi--information] mr-1 size-3"></span>
377
- Inference Sessions require a trained ACT model and create dedicated communication rooms for video inputs,
378
- robot joint states, and control outputs in the inference server system.
379
  </div>
380
  </div>
381
  </Dialog.Content>
382
- </Dialog.Root>
 
21
  let { open = $bindable(), compute, workspaceId }: Props = $props();
22
 
23
  let isConnecting = $state(false);
24
+ let sessionId = $state("");
25
+ let policyPath = $state("./checkpoints/act_so101_beyond");
26
+ let cameraNames = $state("front");
27
  let useProvidedWorkspace = $state(false);
28
 
29
  // Auto-generate session ID when modal opens
 
37
  if (!compute) return;
38
 
39
  if (!sessionId.trim() || !policyPath.trim()) {
40
+ toast.error("Please fill in all required fields");
41
  return;
42
  }
43
 
44
  isConnecting = true;
45
  try {
46
+ const cameras = cameraNames
47
+ .split(",")
48
+ .map((name) => name.trim())
49
+ .filter((name) => name);
50
  if (cameras.length === 0) {
51
+ cameras.push("front");
52
  }
53
 
54
  const config: AISessionConfig = {
 
67
  toast.error(`Failed to create session: ${result.error}`);
68
  }
69
  } catch (error) {
70
+ console.error("Session creation error:", error);
71
+ toast.error("Failed to create session");
72
  } finally {
73
  isConnecting = false;
74
  }
 
81
  try {
82
  const result = await remoteComputeManager.startSession(compute.id);
83
  if (result.success) {
84
+ toast.success("Inference Session started");
85
  } else {
86
  toast.error(`Failed to start session: ${result.error}`);
87
  }
88
  } catch (error) {
89
+ console.error("Session start error:", error);
90
+ toast.error("Failed to start session");
91
  } finally {
92
  isConnecting = false;
93
  }
 
100
  try {
101
  const result = await remoteComputeManager.stopSession(compute.id);
102
  if (result.success) {
103
+ toast.success("Inference Session stopped");
104
  } else {
105
  toast.error(`Failed to stop session: ${result.error}`);
106
  }
107
  } catch (error) {
108
+ console.error("Session stop error:", error);
109
+ toast.error("Failed to stop session");
110
  } finally {
111
  isConnecting = false;
112
  }
 
119
  try {
120
  const result = await remoteComputeManager.deleteSession(compute.id);
121
  if (result.success) {
122
+ toast.success("Inference Session deleted");
123
  } else {
124
  toast.error(`Failed to delete session: ${result.error}`);
125
  }
126
  } catch (error) {
127
+ console.error("Session delete error:", error);
128
+ toast.error("Failed to delete session");
129
  } finally {
130
  isConnecting = false;
131
  }
 
137
  class="max-h-[80vh] max-w-2xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
138
  >
139
  <Dialog.Header class="pb-3">
140
+ <Dialog.Title
141
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
142
+ >
143
  <span class="icon-[mdi--robot-outline] size-5 text-purple-500 dark:text-purple-400"></span>
144
+ AI Compute Session - {compute.name || "No Compute Selected"}
145
  </Dialog.Title>
146
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
147
  Configure and manage ACT model inference sessions for robot control
 
155
  >
156
  <div class="flex items-center gap-2">
157
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
158
+ <span class="text-sm font-medium text-purple-700 dark:text-purple-300"
159
+ >Session Status</span
160
+ >
161
  </div>
162
  {#if compute.hasSession}
163
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
164
  {compute.statusInfo.statusText}
165
  </Badge>
166
  {:else}
167
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
168
+ >No Session</Badge
169
+ >
170
  {/if}
171
  </div>
172
 
173
  <!-- Current Session Details -->
174
  {#if compute.hasSession && compute.sessionData}
175
+ <Card.Root
176
+ class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5"
177
+ >
178
  <Card.Header>
179
+ <Card.Title
180
+ class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200"
181
+ >
182
  <span class="icon-[mdi--cog] size-4"></span>
183
  Current Session
184
  </Card.Title>
185
  </Card.Header>
186
  <Card.Content>
187
  <div class="space-y-3">
188
+ <div
189
+ class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20"
190
+ >
191
  <div class="grid grid-cols-2 gap-2 text-xs">
192
  <div>
193
+ <span class="font-medium text-purple-700 dark:text-purple-300">Session ID:</span
194
+ >
195
+ <span class="block text-purple-800 dark:text-purple-100"
196
+ >{compute.sessionId}</span
197
+ >
198
  </div>
199
  <div>
200
+ <span class="font-medium text-purple-700 dark:text-purple-300">Status:</span>
201
+ <span class="block text-purple-800 dark:text-purple-100"
202
+ >{compute.statusInfo.emoji} {compute.statusInfo.statusText}</span
203
+ >
204
  </div>
205
  <div>
206
+ <span class="font-medium text-purple-700 dark:text-purple-300">Policy:</span>
207
+ <span class="block text-purple-800 dark:text-purple-100"
208
+ >{compute.sessionConfig?.policyPath}</span
209
+ >
210
  </div>
211
  <div>
212
+ <span class="font-medium text-purple-700 dark:text-purple-300">Cameras:</span>
213
+ <span class="block text-purple-800 dark:text-purple-100"
214
+ >{compute.sessionConfig?.cameraNames.join(", ")}</span
215
+ >
216
  </div>
217
  </div>
218
  </div>
219
 
220
  <!-- Connection Details -->
221
+ <div
222
+ class="rounded-lg border border-green-300/30 bg-green-100/20 p-3 dark:border-green-500/30 dark:bg-green-900/20"
223
+ >
224
+ <div class="mb-2 text-sm font-medium text-green-700 dark:text-green-300">
225
+ 📡 Inference Server Connections
226
+ </div>
227
  <div class="space-y-1 text-xs">
228
  <div>
229
  <span class="text-green-600 dark:text-green-400">Workspace:</span>
230
+ <span class="ml-2 font-mono text-green-700 dark:text-green-200"
231
+ >{compute.sessionData.workspace_id}</span
232
+ >
233
  </div>
234
  {#each Object.entries(compute.sessionData.camera_room_ids) as [camera, roomId]}
235
  <div>
236
  <span class="text-green-600 dark:text-green-400">📹 {camera}:</span>
237
+ <span class="ml-2 font-mono text-green-700 dark:text-green-200">{roomId}</span
238
+ >
239
  </div>
240
  {/each}
241
  <div>
242
  <span class="text-green-600 dark:text-green-400">📥 Joint Input:</span>
243
+ <span class="ml-2 font-mono text-green-700 dark:text-green-200"
244
+ >{compute.sessionData.joint_input_room_id}</span
245
+ >
246
  </div>
247
  <div>
248
  <span class="text-green-600 dark:text-green-400">📤 Joint Output:</span>
249
+ <span class="ml-2 font-mono text-green-700 dark:text-green-200"
250
+ >{compute.sessionData.joint_output_room_id}</span
251
+ >
252
  </div>
253
  </div>
254
  </div>
 
261
  size="sm"
262
  onclick={handleStartSession}
263
  disabled={isConnecting}
264
+ class="bg-green-500 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
265
  >
266
  {#if isConnecting}
267
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
268
  Starting...
269
  {:else}
270
  <span class="icon-[mdi--play] mr-1 size-3"></span>
 
281
  class="text-xs disabled:opacity-50"
282
  >
283
  {#if isConnecting}
284
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
285
  Stopping...
286
  {:else}
287
  <span class="icon-[mdi--stop] mr-1 size-3"></span>
 
297
  class="text-xs disabled:opacity-50"
298
  >
299
  {#if isConnecting}
300
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
301
  Deleting...
302
  {:else}
303
  <span class="icon-[mdi--delete] mr-1 size-3"></span>
 
312
 
313
  <!-- Create New Session -->
314
  {#if !compute.hasSession}
315
+ <Card.Root
316
+ class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5"
317
+ >
318
  <Card.Header>
319
+ <Card.Title
320
+ class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200"
321
+ >
322
  <span class="icon-[mdi--plus-circle] size-4"></span>
323
  Create Inference Session
324
  </Card.Title>
 
327
  <div class="space-y-4">
328
  <div class="grid grid-cols-2 gap-4">
329
  <div class="space-y-2">
330
+ <Label for="sessionId" class="text-purple-700 dark:text-purple-300"
331
+ >Session ID</Label
332
+ >
333
  <Input
334
  id="sessionId"
335
  bind:value={sessionId}
336
  placeholder="my-session-01"
337
+ class="border-slate-300 bg-slate-50 text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
338
  />
339
  </div>
340
  <div class="space-y-2">
341
+ <Label for="policyPath" class="text-purple-700 dark:text-purple-300"
342
+ >Policy Path</Label
343
+ >
344
  <Input
345
  id="policyPath"
346
  bind:value={policyPath}
347
  placeholder="./checkpoints/act_so101_beyond"
348
+ class="border-slate-300 bg-slate-50 text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
349
  />
350
  </div>
351
  </div>
352
 
353
  <div class="grid grid-cols-2 gap-4">
354
  <div class="space-y-2">
355
+ <Label for="cameraNames" class="text-purple-700 dark:text-purple-300"
356
+ >Camera Names</Label
357
+ >
358
  <Input
359
  id="cameraNames"
360
  bind:value={cameraNames}
361
  placeholder="front, wrist, overhead"
362
+ class="border-slate-300 bg-slate-50 text-slate-900 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
363
  />
364
+ <p class="text-xs text-slate-600 dark:text-slate-400">
365
+ Comma-separated camera names
366
+ </p>
367
  </div>
368
  <div class="space-y-2">
369
+ <Label for="transportServerUrl" class="text-purple-700 dark:text-purple-300"
370
+ >Transport Server URL</Label
371
+ >
372
  <Input
373
  id="transportServerUrl"
374
  value={settings.transportServerUrl}
375
  disabled
376
  placeholder="http://localhost:8000"
377
+ class="cursor-not-allowed border-slate-300 bg-slate-50 text-slate-900 opacity-60 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
378
  title="Change this value in the settings panel"
379
  />
380
+ <p class="text-xs text-slate-600 dark:text-slate-400">
381
+ Configure in settings panel
382
+ </p>
383
  </div>
384
  </div>
385
 
 
390
  bind:checked={useProvidedWorkspace}
391
  class="rounded border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-800"
392
  />
393
+ <Label for="useWorkspace" class="text-sm text-purple-700 dark:text-purple-300">
394
  Use current workspace ({workspaceId})
395
  </Label>
396
  </div>
 
398
  <Alert.Root>
399
  <span class="icon-[mdi--information] size-4"></span>
400
  <Alert.Description>
401
+ This will create a new ACT inference session with dedicated rooms for camera
402
+ inputs, joint inputs, and joint outputs in the inference server communication
403
+ system.
404
  </Alert.Description>
405
  </Alert.Root>
406
 
 
411
  class="w-full bg-purple-500 hover:bg-purple-600 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
412
  >
413
  {#if isConnecting}
414
+ <span class="icon-[mdi--loading] mr-2 size-4 animate-spin"></span>
415
  Creating Session...
416
  {:else}
417
  <span class="icon-[mdi--rocket-launch] mr-2 size-4"></span>
 
424
  {/if}
425
 
426
  <!-- Quick Info -->
427
+ <div
428
+ class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500"
429
+ >
430
  <span class="icon-[mdi--information] mr-1 size-3"></span>
431
+ Inference Sessions require a trained ACT model and create dedicated communication rooms for video
432
+ inputs, robot joint states, and control outputs in the inference server system.
433
  </div>
434
  </div>
435
  </Dialog.Content>
436
+ </Dialog.Root>
src/lib/components/3d/elements/compute/modal/RobotInputConnectionModal.svelte CHANGED
@@ -16,7 +16,7 @@
16
  let { open = $bindable(), compute, workspaceId }: Props = $props();
17
 
18
  let isConnecting = $state(false);
19
- let selectedRobotId = $state('');
20
  let robotProducer: any = null;
21
  let connectedRobotId = $state<string | null>(null);
22
 
@@ -25,12 +25,12 @@
25
 
26
  async function handleConnectRobotInput() {
27
  if (!compute.hasSession) {
28
- toast.error('No Inference Session available. Create a session first.');
29
  return;
30
  }
31
 
32
  if (!selectedRobotId) {
33
- toast.error('Please select a robot to connect.');
34
  return;
35
  }
36
 
@@ -39,11 +39,11 @@
39
  // Get the joint input room ID from the Inference Session
40
  const jointInputRoomId = compute.sessionData?.joint_input_room_id;
41
  if (!jointInputRoomId) {
42
- throw new Error('No joint input room found in Inference Session');
43
  }
44
 
45
  // Find the selected robot
46
- const robot = robotManager.robots.find(r => r.id === selectedRobotId);
47
  if (!robot) {
48
  throw new Error(`Robot ${selectedRobotId} not found`);
49
  }
@@ -53,14 +53,13 @@
53
 
54
  connectedRobotId = selectedRobotId;
55
 
56
- toast.success('Robot input connected to Inference Session', {
57
  description: `Robot ${selectedRobotId} now sends joint data to AI`
58
  });
59
-
60
  } catch (error) {
61
- console.error('Robot input connection error:', error);
62
- toast.error('Failed to connect robot input', {
63
- description: error instanceof Error ? error.message : 'Unknown error'
64
  });
65
  } finally {
66
  isConnecting = false;
@@ -72,7 +71,7 @@
72
 
73
  try {
74
  // Find the connected robot
75
- const robot = robotManager.robots.find(r => r.id === connectedRobotId);
76
  if (robot) {
77
  // Disconnect producer from the joint input room
78
  for (const producer of robot.producers) {
@@ -81,10 +80,10 @@
81
  }
82
 
83
  connectedRobotId = null;
84
- toast.success('Robot input disconnected');
85
  } catch (error) {
86
- console.error('Disconnect error:', error);
87
- toast.error('Error disconnecting robot input');
88
  }
89
  }
90
 
@@ -101,9 +100,11 @@
101
  class="max-h-[80vh] max-w-xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
102
  >
103
  <Dialog.Header class="pb-3">
104
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
 
 
105
  <span class="icon-[mdi--robot-industrial] size-5 text-amber-500 dark:text-amber-400"></span>
106
- Robot Input - {compute.name || 'No Compute Selected'}
107
  </Dialog.Title>
108
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
109
  Connect robot joint data as input for AI inference
@@ -117,35 +118,47 @@
117
  >
118
  <div class="flex items-center gap-2">
119
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
120
- <span class="text-sm font-medium text-purple-700 dark:text-purple-300">Inference Session</span>
 
 
121
  </div>
122
  {#if compute.hasSession}
123
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
124
  {compute.statusInfo.statusText}
125
  </Badge>
126
  {:else}
127
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Session</Badge>
 
 
128
  {/if}
129
  </div>
130
 
131
  {#if !compute.hasSession}
132
- <Card.Root class="border-yellow-300/30 bg-yellow-100/5 dark:border-yellow-500/30 dark:bg-yellow-500/5">
 
 
133
  <Card.Header>
134
- <Card.Title class="flex items-center gap-2 text-base text-yellow-700 dark:text-yellow-200">
 
 
135
  <span class="icon-[mdi--alert] size-4"></span>
136
  Inference Session Required
137
  </Card.Title>
138
  </Card.Header>
139
  <Card.Content class="text-sm text-yellow-700 dark:text-yellow-300">
140
- You need to create an Inference Session before connecting robot inputs.
141
- The session provides a joint input room for receiving robot data.
142
  </Card.Content>
143
  </Card.Root>
144
  {:else}
145
  <!-- Robot Selection and Connection -->
146
- <Card.Root class="border-amber-300/30 bg-amber-100/5 dark:border-amber-500/30 dark:bg-amber-500/5">
 
 
147
  <Card.Header>
148
- <Card.Title class="flex items-center gap-2 text-base text-amber-700 dark:text-amber-200">
 
 
149
  <span class="icon-[mdi--robot-industrial] size-4"></span>
150
  Robot Input Connection
151
  </Card.Title>
@@ -153,18 +166,20 @@
153
  <Card.Content class="space-y-4">
154
  <!-- Available Robots -->
155
  <div class="space-y-2">
156
- <div class="text-sm font-medium text-amber-700 dark:text-amber-300">Available Robots:</div>
157
- <div class="max-h-40 overflow-y-auto space-y-2">
 
 
158
  {#if robots.length === 0}
159
- <div class="text-center py-4 text-sm text-slate-600 dark:text-slate-400">
160
  No robots available. Add robots first.
161
  </div>
162
  {:else}
163
  {#each robots as robot}
164
  <button
165
- onclick={() => selectedRobotId = robot.id}
166
- class="w-full p-3 rounded border text-left {selectedRobotId === robot.id
167
- ? 'border-amber-400 bg-amber-100/20 dark:border-amber-500 dark:bg-amber-500/20'
168
  : 'border-slate-300 bg-slate-50/50 hover:bg-slate-100/50 dark:border-slate-600 dark:bg-slate-800/50 dark:hover:bg-slate-700/50'}"
169
  >
170
  <div class="flex items-center justify-between">
@@ -182,9 +197,7 @@
182
  Active
183
  </Badge>
184
  {:else}
185
- <Badge variant="secondary" class="text-xs">
186
- Available
187
- </Badge>
188
  {/if}
189
  </div>
190
  </div>
@@ -196,14 +209,16 @@
196
 
197
  <!-- Connection Status -->
198
  {#if selectedRobotId}
199
- <div class="rounded-lg border border-amber-300/30 bg-amber-100/20 p-3 dark:border-amber-500/30 dark:bg-amber-900/20">
 
 
200
  <div class="flex items-center justify-between">
201
  <div>
202
  <p class="text-sm font-medium text-amber-700 dark:text-amber-300">
203
  Selected Robot: {selectedRobotId}
204
  </p>
205
  <p class="text-xs text-amber-600/70 dark:text-amber-400/70">
206
- {connectedRobotId === selectedRobotId ? 'Connected to AI' : 'Not Connected'}
207
  </p>
208
  </div>
209
  {#if connectedRobotId !== selectedRobotId}
@@ -212,10 +227,10 @@
212
  size="sm"
213
  onclick={handleConnectRobotInput}
214
  disabled={isConnecting}
215
- class="bg-amber-500 hover:bg-amber-600 text-xs disabled:opacity-50 dark:bg-amber-600 dark:hover:bg-amber-700"
216
  >
217
  {#if isConnecting}
218
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
219
  Connecting...
220
  {:else}
221
  <span class="icon-[mdi--link] mr-1 size-3"></span>
@@ -240,7 +255,9 @@
240
  </Card.Root>
241
 
242
  <!-- Session Joint Input Details -->
243
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
 
 
244
  <Card.Header>
245
  <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
246
  <span class="icon-[mdi--information] size-4"></span>
@@ -249,13 +266,17 @@
249
  </Card.Header>
250
  <Card.Content>
251
  <div class="space-y-2 text-xs">
252
- <div class="flex justify-between items-center p-2 rounded bg-slate-100/50 dark:bg-slate-800/50">
253
- <span class="text-blue-700 font-medium dark:text-blue-300">Joint Input Room:</span>
254
- <span class="text-blue-800 font-mono dark:text-blue-200">{compute.sessionData?.joint_input_room_id}</span>
 
 
 
 
255
  </div>
256
- <div class="text-slate-600 text-xs dark:text-slate-400">
257
- The robot will act as a <strong>PRODUCER</strong> and send its current joint positions to this room for AI processing.
258
- The inference server receives this data as a CONSUMER.
259
  All joint values should be normalized (-100 to +100 for most joints, 0 to 100 for gripper).
260
  </div>
261
  </div>
@@ -264,17 +285,22 @@
264
 
265
  <!-- Connection Status -->
266
  {#if connectedRobotId}
267
- <Card.Root class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5">
 
 
268
  <Card.Header>
269
- <Card.Title class="flex items-center gap-2 text-base text-green-700 dark:text-green-200">
 
 
270
  <span class="icon-[mdi--check-circle] size-4"></span>
271
  Active Connection
272
  </Card.Title>
273
  </Card.Header>
274
  <Card.Content>
275
  <div class="text-sm text-green-700 dark:text-green-300">
276
- Robot <span class="font-mono">{connectedRobotId}</span> is now sending joint data to the Inference Session as a producer.
277
- The AI model will use this data along with camera inputs for inference.
 
278
  </div>
279
  </Card.Content>
280
  </Card.Root>
@@ -282,10 +308,13 @@
282
  {/if}
283
 
284
  <!-- Quick Info -->
285
- <div class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500">
 
 
286
  <span class="icon-[mdi--information] mr-1 size-3"></span>
287
- Robot input: Robot acts as PRODUCER sending joint positions → Inference server acts as CONSUMER receiving data for processing.
 
288
  </div>
289
  </div>
290
  </Dialog.Content>
291
- </Dialog.Root>
 
16
  let { open = $bindable(), compute, workspaceId }: Props = $props();
17
 
18
  let isConnecting = $state(false);
19
+ let selectedRobotId = $state("");
20
  let robotProducer: any = null;
21
  let connectedRobotId = $state<string | null>(null);
22
 
 
25
 
26
  async function handleConnectRobotInput() {
27
  if (!compute.hasSession) {
28
+ toast.error("No Inference Session available. Create a session first.");
29
  return;
30
  }
31
 
32
  if (!selectedRobotId) {
33
+ toast.error("Please select a robot to connect.");
34
  return;
35
  }
36
 
 
39
  // Get the joint input room ID from the Inference Session
40
  const jointInputRoomId = compute.sessionData?.joint_input_room_id;
41
  if (!jointInputRoomId) {
42
+ throw new Error("No joint input room found in Inference Session");
43
  }
44
 
45
  // Find the selected robot
46
+ const robot = robotManager.robots.find((r) => r.id === selectedRobotId);
47
  if (!robot) {
48
  throw new Error(`Robot ${selectedRobotId} not found`);
49
  }
 
53
 
54
  connectedRobotId = selectedRobotId;
55
 
56
+ toast.success("Robot input connected to Inference Session", {
57
  description: `Robot ${selectedRobotId} now sends joint data to AI`
58
  });
 
59
  } catch (error) {
60
+ console.error("Robot input connection error:", error);
61
+ toast.error("Failed to connect robot input", {
62
+ description: error instanceof Error ? error.message : "Unknown error"
63
  });
64
  } finally {
65
  isConnecting = false;
 
71
 
72
  try {
73
  // Find the connected robot
74
+ const robot = robotManager.robots.find((r) => r.id === connectedRobotId);
75
  if (robot) {
76
  // Disconnect producer from the joint input room
77
  for (const producer of robot.producers) {
 
80
  }
81
 
82
  connectedRobotId = null;
83
+ toast.success("Robot input disconnected");
84
  } catch (error) {
85
+ console.error("Disconnect error:", error);
86
+ toast.error("Error disconnecting robot input");
87
  }
88
  }
89
 
 
100
  class="max-h-[80vh] max-w-xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
101
  >
102
  <Dialog.Header class="pb-3">
103
+ <Dialog.Title
104
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
105
+ >
106
  <span class="icon-[mdi--robot-industrial] size-5 text-amber-500 dark:text-amber-400"></span>
107
+ Robot Input - {compute.name || "No Compute Selected"}
108
  </Dialog.Title>
109
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
110
  Connect robot joint data as input for AI inference
 
118
  >
119
  <div class="flex items-center gap-2">
120
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
121
+ <span class="text-sm font-medium text-purple-700 dark:text-purple-300"
122
+ >Inference Session</span
123
+ >
124
  </div>
125
  {#if compute.hasSession}
126
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
127
  {compute.statusInfo.statusText}
128
  </Badge>
129
  {:else}
130
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
131
+ >No Session</Badge
132
+ >
133
  {/if}
134
  </div>
135
 
136
  {#if !compute.hasSession}
137
+ <Card.Root
138
+ class="border-yellow-300/30 bg-yellow-100/5 dark:border-yellow-500/30 dark:bg-yellow-500/5"
139
+ >
140
  <Card.Header>
141
+ <Card.Title
142
+ class="flex items-center gap-2 text-base text-yellow-700 dark:text-yellow-200"
143
+ >
144
  <span class="icon-[mdi--alert] size-4"></span>
145
  Inference Session Required
146
  </Card.Title>
147
  </Card.Header>
148
  <Card.Content class="text-sm text-yellow-700 dark:text-yellow-300">
149
+ You need to create an Inference Session before connecting robot inputs. The session
150
+ provides a joint input room for receiving robot data.
151
  </Card.Content>
152
  </Card.Root>
153
  {:else}
154
  <!-- Robot Selection and Connection -->
155
+ <Card.Root
156
+ class="border-amber-300/30 bg-amber-100/5 dark:border-amber-500/30 dark:bg-amber-500/5"
157
+ >
158
  <Card.Header>
159
+ <Card.Title
160
+ class="flex items-center gap-2 text-base text-amber-700 dark:text-amber-200"
161
+ >
162
  <span class="icon-[mdi--robot-industrial] size-4"></span>
163
  Robot Input Connection
164
  </Card.Title>
 
166
  <Card.Content class="space-y-4">
167
  <!-- Available Robots -->
168
  <div class="space-y-2">
169
+ <div class="text-sm font-medium text-amber-700 dark:text-amber-300">
170
+ Available Robots:
171
+ </div>
172
+ <div class="max-h-40 space-y-2 overflow-y-auto">
173
  {#if robots.length === 0}
174
+ <div class="py-4 text-center text-sm text-slate-600 dark:text-slate-400">
175
  No robots available. Add robots first.
176
  </div>
177
  {:else}
178
  {#each robots as robot}
179
  <button
180
+ onclick={() => (selectedRobotId = robot.id)}
181
+ class="w-full rounded border p-3 text-left {selectedRobotId === robot.id
182
+ ? 'border-amber-400 bg-amber-100/20 dark:border-amber-500 dark:bg-amber-500/20'
183
  : 'border-slate-300 bg-slate-50/50 hover:bg-slate-100/50 dark:border-slate-600 dark:bg-slate-800/50 dark:hover:bg-slate-700/50'}"
184
  >
185
  <div class="flex items-center justify-between">
 
197
  Active
198
  </Badge>
199
  {:else}
200
+ <Badge variant="secondary" class="text-xs">Available</Badge>
 
 
201
  {/if}
202
  </div>
203
  </div>
 
209
 
210
  <!-- Connection Status -->
211
  {#if selectedRobotId}
212
+ <div
213
+ class="rounded-lg border border-amber-300/30 bg-amber-100/20 p-3 dark:border-amber-500/30 dark:bg-amber-900/20"
214
+ >
215
  <div class="flex items-center justify-between">
216
  <div>
217
  <p class="text-sm font-medium text-amber-700 dark:text-amber-300">
218
  Selected Robot: {selectedRobotId}
219
  </p>
220
  <p class="text-xs text-amber-600/70 dark:text-amber-400/70">
221
+ {connectedRobotId === selectedRobotId ? "Connected to AI" : "Not Connected"}
222
  </p>
223
  </div>
224
  {#if connectedRobotId !== selectedRobotId}
 
227
  size="sm"
228
  onclick={handleConnectRobotInput}
229
  disabled={isConnecting}
230
+ class="bg-amber-500 text-xs hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-600 dark:hover:bg-amber-700"
231
  >
232
  {#if isConnecting}
233
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
234
  Connecting...
235
  {:else}
236
  <span class="icon-[mdi--link] mr-1 size-3"></span>
 
255
  </Card.Root>
256
 
257
  <!-- Session Joint Input Details -->
258
+ <Card.Root
259
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
260
+ >
261
  <Card.Header>
262
  <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
263
  <span class="icon-[mdi--information] size-4"></span>
 
266
  </Card.Header>
267
  <Card.Content>
268
  <div class="space-y-2 text-xs">
269
+ <div
270
+ class="flex items-center justify-between rounded bg-slate-100/50 p-2 dark:bg-slate-800/50"
271
+ >
272
+ <span class="font-medium text-blue-700 dark:text-blue-300">Joint Input Room:</span>
273
+ <span class="font-mono text-blue-800 dark:text-blue-200"
274
+ >{compute.sessionData?.joint_input_room_id}</span
275
+ >
276
  </div>
277
+ <div class="text-xs text-slate-600 dark:text-slate-400">
278
+ The robot will act as a <strong>PRODUCER</strong> and send its current joint positions
279
+ to this room for AI processing. The inference server receives this data as a CONSUMER.
280
  All joint values should be normalized (-100 to +100 for most joints, 0 to 100 for gripper).
281
  </div>
282
  </div>
 
285
 
286
  <!-- Connection Status -->
287
  {#if connectedRobotId}
288
+ <Card.Root
289
+ class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5"
290
+ >
291
  <Card.Header>
292
+ <Card.Title
293
+ class="flex items-center gap-2 text-base text-green-700 dark:text-green-200"
294
+ >
295
  <span class="icon-[mdi--check-circle] size-4"></span>
296
  Active Connection
297
  </Card.Title>
298
  </Card.Header>
299
  <Card.Content>
300
  <div class="text-sm text-green-700 dark:text-green-300">
301
+ Robot <span class="font-mono">{connectedRobotId}</span> is now sending joint data to
302
+ the Inference Session as a producer. The AI model will use this data along with camera
303
+ inputs for inference.
304
  </div>
305
  </Card.Content>
306
  </Card.Root>
 
308
  {/if}
309
 
310
  <!-- Quick Info -->
311
+ <div
312
+ class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500"
313
+ >
314
  <span class="icon-[mdi--information] mr-1 size-3"></span>
315
+ Robot input: Robot acts as PRODUCER sending joint positions → Inference server acts as CONSUMER
316
+ receiving data for processing.
317
  </div>
318
  </div>
319
  </Dialog.Content>
320
+ </Dialog.Root>
src/lib/components/3d/elements/compute/modal/RobotOutputConnectionModal.svelte CHANGED
@@ -16,7 +16,7 @@
16
  let { open = $bindable(), compute, workspaceId }: Props = $props();
17
 
18
  let isConnecting = $state(false);
19
- let selectedRobotId = $state('');
20
  let robotConsumer: any = null;
21
  let connectedRobotId = $state<string | null>(null);
22
 
@@ -25,12 +25,12 @@
25
 
26
  async function handleConnectRobotOutput() {
27
  if (!compute.hasSession) {
28
- toast.error('No Inference Session available. Create a session first.');
29
  return;
30
  }
31
 
32
  if (!selectedRobotId) {
33
- toast.error('Please select a robot to connect.');
34
  return;
35
  }
36
 
@@ -39,11 +39,11 @@
39
  // Get the joint output room ID from the Inference Session
40
  const jointOutputRoomId = compute.sessionData?.joint_output_room_id;
41
  if (!jointOutputRoomId) {
42
- throw new Error('No joint output room found in Inference Session');
43
  }
44
 
45
  // Find the selected robot
46
- const robot = robotManager.robots.find(r => r.id === selectedRobotId);
47
  if (!robot) {
48
  throw new Error(`Robot ${selectedRobotId} not found`);
49
  }
@@ -53,14 +53,13 @@
53
 
54
  connectedRobotId = selectedRobotId;
55
 
56
- toast.success('Robot output connected to Inference Session', {
57
  description: `Robot ${selectedRobotId} now receives AI commands`
58
  });
59
-
60
  } catch (error) {
61
- console.error('Robot output connection error:', error);
62
- toast.error('Failed to connect robot output', {
63
- description: error instanceof Error ? error.message : 'Unknown error'
64
  });
65
  } finally {
66
  isConnecting = false;
@@ -72,16 +71,16 @@
72
 
73
  try {
74
  // Find the connected robot
75
- const robot = robotManager.robots.find(r => r.id === connectedRobotId);
76
  if (robot) {
77
  await robot.removeConsumer();
78
  }
79
 
80
  connectedRobotId = null;
81
- toast.success('Robot output disconnected');
82
  } catch (error) {
83
- console.error('Disconnect error:', error);
84
- toast.error('Error disconnecting robot output');
85
  }
86
  }
87
 
@@ -98,9 +97,11 @@
98
  class="max-h-[80vh] max-w-xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
99
  >
100
  <Dialog.Header class="pb-3">
101
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
 
 
102
  <span class="icon-[mdi--robot-outline] size-5 text-blue-500 dark:text-blue-400"></span>
103
- Robot Output - {compute.name || 'No Compute Selected'}
104
  </Dialog.Title>
105
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
106
  Connect AI command output to control robot actuators
@@ -114,33 +115,43 @@
114
  >
115
  <div class="flex items-center gap-2">
116
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
117
- <span class="text-sm font-medium text-purple-700 dark:text-purple-300">Inference Session</span>
 
 
118
  </div>
119
  {#if compute.hasSession}
120
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
121
  {compute.statusInfo.statusText}
122
  </Badge>
123
  {:else}
124
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Session</Badge>
 
 
125
  {/if}
126
  </div>
127
 
128
  {#if !compute.hasSession}
129
- <Card.Root class="border-yellow-300/30 bg-yellow-100/5 dark:border-yellow-500/30 dark:bg-yellow-500/5">
 
 
130
  <Card.Header>
131
- <Card.Title class="flex items-center gap-2 text-base text-yellow-700 dark:text-yellow-200">
 
 
132
  <span class="icon-[mdi--alert] size-4"></span>
133
  Inference Session Required
134
  </Card.Title>
135
  </Card.Header>
136
  <Card.Content class="text-sm text-yellow-700 dark:text-yellow-300">
137
- You need to create an Inference Session before connecting robot outputs.
138
- The session provides a joint output room for sending AI commands.
139
  </Card.Content>
140
  </Card.Root>
141
  {:else}
142
  <!-- Robot Selection and Connection -->
143
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
 
 
144
  <Card.Header>
145
  <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
146
  <span class="icon-[mdi--robot-outline] size-4"></span>
@@ -150,18 +161,20 @@
150
  <Card.Content class="space-y-4">
151
  <!-- Available Robots -->
152
  <div class="space-y-2">
153
- <div class="text-sm font-medium text-blue-700 dark:text-blue-300">Available Robots:</div>
154
- <div class="max-h-40 overflow-y-auto space-y-2">
 
 
155
  {#if robots.length === 0}
156
- <div class="text-center py-4 text-sm text-slate-600 dark:text-slate-400">
157
  No robots available. Add robots first.
158
  </div>
159
  {:else}
160
  {#each robots as robot}
161
  <button
162
- onclick={() => selectedRobotId = robot.id}
163
- class="w-full p-3 rounded border text-left {selectedRobotId === robot.id
164
- ? 'border-blue-400 bg-blue-100/20 dark:border-blue-500 dark:bg-blue-500/20'
165
  : 'border-slate-300 bg-slate-50/50 hover:bg-slate-100/50 dark:border-slate-600 dark:bg-slate-800/50 dark:hover:bg-slate-700/50'}"
166
  >
167
  <div class="flex items-center justify-between">
@@ -170,7 +183,7 @@
170
  ID: {robot.id}
171
  </div>
172
  <div class="text-xs text-slate-600 dark:text-slate-400">
173
- Consumer: {robot.hasConsumer ? 'Connected' : 'None'}
174
  </div>
175
  </div>
176
  <div class="flex items-center gap-2">
@@ -179,9 +192,7 @@
179
  Active
180
  </Badge>
181
  {:else}
182
- <Badge variant="secondary" class="text-xs">
183
- Available
184
- </Badge>
185
  {/if}
186
  </div>
187
  </div>
@@ -193,14 +204,16 @@
193
 
194
  <!-- Connection Status -->
195
  {#if selectedRobotId}
196
- <div class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20">
 
 
197
  <div class="flex items-center justify-between">
198
  <div>
199
  <p class="text-sm font-medium text-blue-700 dark:text-blue-300">
200
  Selected Robot: {selectedRobotId}
201
  </p>
202
  <p class="text-xs text-blue-600/70 dark:text-blue-400/70">
203
- {connectedRobotId === selectedRobotId ? 'Connected to AI' : 'Not Connected'}
204
  </p>
205
  </div>
206
  {#if connectedRobotId !== selectedRobotId}
@@ -209,10 +222,10 @@
209
  size="sm"
210
  onclick={handleConnectRobotOutput}
211
  disabled={isConnecting}
212
- class="bg-blue-500 hover:bg-blue-600 text-xs disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
213
  >
214
  {#if isConnecting}
215
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
216
  Connecting...
217
  {:else}
218
  <span class="icon-[mdi--link] mr-1 size-3"></span>
@@ -237,22 +250,32 @@
237
  </Card.Root>
238
 
239
  <!-- Session Joint Output Details -->
240
- <Card.Root class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5">
 
 
241
  <Card.Header>
242
- <Card.Title class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200">
 
 
243
  <span class="icon-[mdi--information] size-4"></span>
244
  Data Flow: Inference Session → Robot
245
  </Card.Title>
246
  </Card.Header>
247
  <Card.Content>
248
  <div class="space-y-2 text-xs">
249
- <div class="flex justify-between items-center p-2 rounded bg-slate-100/50 dark:bg-slate-800/50">
250
- <span class="text-orange-700 font-medium dark:text-orange-300">Joint Output Room:</span>
251
- <span class="text-orange-800 font-mono dark:text-orange-200">{compute.sessionData?.joint_output_room_id}</span>
 
 
 
 
 
 
252
  </div>
253
- <div class="text-slate-600 text-xs dark:text-slate-400">
254
- The inference server will act as a <strong>PRODUCER</strong> and send predicted joint commands to this room for robot execution.
255
- The robot receives this data as a CONSUMER.
256
  All joint values will be normalized (-100 to +100 for most joints, 0 to 100 for gripper).
257
  </div>
258
  </div>
@@ -261,17 +284,21 @@
261
 
262
  <!-- Connection Status -->
263
  {#if connectedRobotId}
264
- <Card.Root class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5">
 
 
265
  <Card.Header>
266
- <Card.Title class="flex items-center gap-2 text-base text-green-700 dark:text-green-200">
 
 
267
  <span class="icon-[mdi--check-circle] size-4"></span>
268
  Active Connection
269
  </Card.Title>
270
  </Card.Header>
271
  <Card.Content>
272
  <div class="text-sm text-green-700 dark:text-green-300">
273
- Robot <span class="font-mono">{connectedRobotId}</span> is now receiving AI commands as a consumer.
274
- The robot will execute joint movements based on AI inference results.
275
  </div>
276
  </Card.Content>
277
  </Card.Root>
@@ -279,10 +306,13 @@
279
  {/if}
280
 
281
  <!-- Quick Info -->
282
- <div class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500">
 
 
283
  <span class="icon-[mdi--information] mr-1 size-3"></span>
284
- Robot output: Inference server acts as PRODUCER sending commands → Robot acts as CONSUMER receiving and executing movements.
 
285
  </div>
286
  </div>
287
  </Dialog.Content>
288
- </Dialog.Root>
 
16
  let { open = $bindable(), compute, workspaceId }: Props = $props();
17
 
18
  let isConnecting = $state(false);
19
+ let selectedRobotId = $state("");
20
  let robotConsumer: any = null;
21
  let connectedRobotId = $state<string | null>(null);
22
 
 
25
 
26
  async function handleConnectRobotOutput() {
27
  if (!compute.hasSession) {
28
+ toast.error("No Inference Session available. Create a session first.");
29
  return;
30
  }
31
 
32
  if (!selectedRobotId) {
33
+ toast.error("Please select a robot to connect.");
34
  return;
35
  }
36
 
 
39
  // Get the joint output room ID from the Inference Session
40
  const jointOutputRoomId = compute.sessionData?.joint_output_room_id;
41
  if (!jointOutputRoomId) {
42
+ throw new Error("No joint output room found in Inference Session");
43
  }
44
 
45
  // Find the selected robot
46
+ const robot = robotManager.robots.find((r) => r.id === selectedRobotId);
47
  if (!robot) {
48
  throw new Error(`Robot ${selectedRobotId} not found`);
49
  }
 
53
 
54
  connectedRobotId = selectedRobotId;
55
 
56
+ toast.success("Robot output connected to Inference Session", {
57
  description: `Robot ${selectedRobotId} now receives AI commands`
58
  });
 
59
  } catch (error) {
60
+ console.error("Robot output connection error:", error);
61
+ toast.error("Failed to connect robot output", {
62
+ description: error instanceof Error ? error.message : "Unknown error"
63
  });
64
  } finally {
65
  isConnecting = false;
 
71
 
72
  try {
73
  // Find the connected robot
74
+ const robot = robotManager.robots.find((r) => r.id === connectedRobotId);
75
  if (robot) {
76
  await robot.removeConsumer();
77
  }
78
 
79
  connectedRobotId = null;
80
+ toast.success("Robot output disconnected");
81
  } catch (error) {
82
+ console.error("Disconnect error:", error);
83
+ toast.error("Error disconnecting robot output");
84
  }
85
  }
86
 
 
97
  class="max-h-[80vh] max-w-xl overflow-y-auto border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
98
  >
99
  <Dialog.Header class="pb-3">
100
+ <Dialog.Title
101
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
102
+ >
103
  <span class="icon-[mdi--robot-outline] size-5 text-blue-500 dark:text-blue-400"></span>
104
+ Robot Output - {compute.name || "No Compute Selected"}
105
  </Dialog.Title>
106
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
107
  Connect AI command output to control robot actuators
 
115
  >
116
  <div class="flex items-center gap-2">
117
  <span class="icon-[mdi--brain] size-4 text-purple-500 dark:text-purple-400"></span>
118
+ <span class="text-sm font-medium text-purple-700 dark:text-purple-300"
119
+ >Inference Session</span
120
+ >
121
  </div>
122
  {#if compute.hasSession}
123
  <Badge variant="default" class="bg-purple-500 text-xs dark:bg-purple-600">
124
  {compute.statusInfo.statusText}
125
  </Badge>
126
  {:else}
127
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
128
+ >No Session</Badge
129
+ >
130
  {/if}
131
  </div>
132
 
133
  {#if !compute.hasSession}
134
+ <Card.Root
135
+ class="border-yellow-300/30 bg-yellow-100/5 dark:border-yellow-500/30 dark:bg-yellow-500/5"
136
+ >
137
  <Card.Header>
138
+ <Card.Title
139
+ class="flex items-center gap-2 text-base text-yellow-700 dark:text-yellow-200"
140
+ >
141
  <span class="icon-[mdi--alert] size-4"></span>
142
  Inference Session Required
143
  </Card.Title>
144
  </Card.Header>
145
  <Card.Content class="text-sm text-yellow-700 dark:text-yellow-300">
146
+ You need to create an Inference Session before connecting robot outputs. The session
147
+ provides a joint output room for sending AI commands.
148
  </Card.Content>
149
  </Card.Root>
150
  {:else}
151
  <!-- Robot Selection and Connection -->
152
+ <Card.Root
153
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
154
+ >
155
  <Card.Header>
156
  <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
157
  <span class="icon-[mdi--robot-outline] size-4"></span>
 
161
  <Card.Content class="space-y-4">
162
  <!-- Available Robots -->
163
  <div class="space-y-2">
164
+ <div class="text-sm font-medium text-blue-700 dark:text-blue-300">
165
+ Available Robots:
166
+ </div>
167
+ <div class="max-h-40 space-y-2 overflow-y-auto">
168
  {#if robots.length === 0}
169
+ <div class="py-4 text-center text-sm text-slate-600 dark:text-slate-400">
170
  No robots available. Add robots first.
171
  </div>
172
  {:else}
173
  {#each robots as robot}
174
  <button
175
+ onclick={() => (selectedRobotId = robot.id)}
176
+ class="w-full rounded border p-3 text-left {selectedRobotId === robot.id
177
+ ? 'border-blue-400 bg-blue-100/20 dark:border-blue-500 dark:bg-blue-500/20'
178
  : 'border-slate-300 bg-slate-50/50 hover:bg-slate-100/50 dark:border-slate-600 dark:bg-slate-800/50 dark:hover:bg-slate-700/50'}"
179
  >
180
  <div class="flex items-center justify-between">
 
183
  ID: {robot.id}
184
  </div>
185
  <div class="text-xs text-slate-600 dark:text-slate-400">
186
+ Consumer: {robot.hasConsumer ? "Connected" : "None"}
187
  </div>
188
  </div>
189
  <div class="flex items-center gap-2">
 
192
  Active
193
  </Badge>
194
  {:else}
195
+ <Badge variant="secondary" class="text-xs">Available</Badge>
 
 
196
  {/if}
197
  </div>
198
  </div>
 
204
 
205
  <!-- Connection Status -->
206
  {#if selectedRobotId}
207
+ <div
208
+ class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20"
209
+ >
210
  <div class="flex items-center justify-between">
211
  <div>
212
  <p class="text-sm font-medium text-blue-700 dark:text-blue-300">
213
  Selected Robot: {selectedRobotId}
214
  </p>
215
  <p class="text-xs text-blue-600/70 dark:text-blue-400/70">
216
+ {connectedRobotId === selectedRobotId ? "Connected to AI" : "Not Connected"}
217
  </p>
218
  </div>
219
  {#if connectedRobotId !== selectedRobotId}
 
222
  size="sm"
223
  onclick={handleConnectRobotOutput}
224
  disabled={isConnecting}
225
+ class="bg-blue-500 text-xs hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
226
  >
227
  {#if isConnecting}
228
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
229
  Connecting...
230
  {:else}
231
  <span class="icon-[mdi--link] mr-1 size-3"></span>
 
250
  </Card.Root>
251
 
252
  <!-- Session Joint Output Details -->
253
+ <Card.Root
254
+ class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5"
255
+ >
256
  <Card.Header>
257
+ <Card.Title
258
+ class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200"
259
+ >
260
  <span class="icon-[mdi--information] size-4"></span>
261
  Data Flow: Inference Session → Robot
262
  </Card.Title>
263
  </Card.Header>
264
  <Card.Content>
265
  <div class="space-y-2 text-xs">
266
+ <div
267
+ class="flex items-center justify-between rounded bg-slate-100/50 p-2 dark:bg-slate-800/50"
268
+ >
269
+ <span class="font-medium text-orange-700 dark:text-orange-300"
270
+ >Joint Output Room:</span
271
+ >
272
+ <span class="font-mono text-orange-800 dark:text-orange-200"
273
+ >{compute.sessionData?.joint_output_room_id}</span
274
+ >
275
  </div>
276
+ <div class="text-xs text-slate-600 dark:text-slate-400">
277
+ The inference server will act as a <strong>PRODUCER</strong> and send predicted joint
278
+ commands to this room for robot execution. The robot receives this data as a CONSUMER.
279
  All joint values will be normalized (-100 to +100 for most joints, 0 to 100 for gripper).
280
  </div>
281
  </div>
 
284
 
285
  <!-- Connection Status -->
286
  {#if connectedRobotId}
287
+ <Card.Root
288
+ class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5"
289
+ >
290
  <Card.Header>
291
+ <Card.Title
292
+ class="flex items-center gap-2 text-base text-green-700 dark:text-green-200"
293
+ >
294
  <span class="icon-[mdi--check-circle] size-4"></span>
295
  Active Connection
296
  </Card.Title>
297
  </Card.Header>
298
  <Card.Content>
299
  <div class="text-sm text-green-700 dark:text-green-300">
300
+ Robot <span class="font-mono">{connectedRobotId}</span> is now receiving AI commands
301
+ as a consumer. The robot will execute joint movements based on AI inference results.
302
  </div>
303
  </Card.Content>
304
  </Card.Root>
 
306
  {/if}
307
 
308
  <!-- Quick Info -->
309
+ <div
310
+ class="rounded border border-slate-300 bg-slate-100/30 p-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/30 dark:text-slate-500"
311
+ >
312
  <span class="icon-[mdi--information] mr-1 size-3"></span>
313
+ Robot output: Inference server acts as PRODUCER sending commands → Robot acts as CONSUMER receiving
314
+ and executing movements.
315
  </div>
316
  </div>
317
  </Dialog.Content>
318
+ </Dialog.Root>
src/lib/components/3d/elements/compute/modal/VideoInputConnectionModal.svelte CHANGED
@@ -1,7 +1,7 @@
1
  <script lang="ts">
2
  import * as Dialog from "@/components/ui/dialog";
3
- import { video } from '@robothub/transport-server-client';
4
- import type { video as videoTypes } from '@robothub/transport-server-client';
5
  import { Button } from "@/components/ui/button";
6
  import * as Card from "@/components/ui/card";
7
  import { Badge } from "@/components/ui/badge";
@@ -19,7 +19,7 @@
19
  let { open = $bindable(), compute, workspaceId }: Props = $props();
20
 
21
  let isConnecting = $state(false);
22
- let selectedCameraName = $state('front');
23
  let localStream: MediaStream | null = $state(null);
24
  let videoProducer: any = null;
25
 
@@ -32,7 +32,7 @@
32
 
33
  async function handleConnectLocalCamera() {
34
  if (!compute.hasSession) {
35
- toast.error('No Inference Session available. Create a session first.');
36
  return;
37
  }
38
 
@@ -53,13 +53,13 @@
53
 
54
  // Create video producer and connect to the camera room
55
  videoProducer = new video.VideoProducer(settings.transportServerUrl);
56
-
57
  // Connect to the EXISTING camera room (don't create new one)
58
  const participantId = `frontend-camera-${selectedCameraName}-${Date.now()}`;
59
  const success = await videoProducer.connect(workspaceId, cameraRoomId, participantId);
60
-
61
  if (!success) {
62
- throw new Error('Failed to connect to camera room');
63
  }
64
 
65
  // Start streaming
@@ -68,11 +68,10 @@
68
  toast.success(`Camera connected to Inference Session`, {
69
  description: `Local camera streaming to ${selectedCameraName} input`
70
  });
71
-
72
  } catch (error) {
73
- console.error('Camera connection error:', error);
74
- toast.error('Failed to connect camera', {
75
- description: error instanceof Error ? error.message : 'Unknown error'
76
  });
77
  } finally {
78
  isConnecting = false;
@@ -88,25 +87,25 @@
88
  }
89
 
90
  if (localStream) {
91
- localStream.getTracks().forEach(track => track.stop());
92
  localStream = null;
93
  }
94
 
95
- toast.success('Camera disconnected');
96
  } catch (error) {
97
- console.error('Disconnect error:', error);
98
- toast.error('Error disconnecting camera');
99
  }
100
  }
101
 
102
  // Cleanup on modal close
103
  $effect(() => {
104
  return () => {
105
- if (!open) {
106
- handleDisconnectCamera();
107
- }
108
- };
109
- });
110
  </script>
111
 
112
  <Dialog.Root bind:open>
@@ -116,7 +115,7 @@
116
  <Dialog.Header class="pb-3">
117
  <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-100">
118
  <span class="icon-[mdi--video-input-component] size-5 text-green-400"></span>
119
- Video Input - {compute.name || 'No Compute Selected'}
120
  </Dialog.Title>
121
  <Dialog.Description class="text-sm text-slate-400">
122
  Connect camera streams to provide visual input for AI inference
@@ -150,8 +149,8 @@
150
  </Card.Title>
151
  </Card.Header>
152
  <Card.Content class="text-sm text-yellow-300">
153
- You need to create an Inference Session before connecting video inputs.
154
- The session defines which camera names are available for connection.
155
  </Card.Content>
156
  </Card.Root>
157
  {:else}
@@ -170,9 +169,9 @@
170
  <div class="grid grid-cols-2 gap-2">
171
  {#each compute.sessionConfig?.cameraNames || [] as cameraName}
172
  <button
173
- onclick={() => selectedCameraName = cameraName}
174
- class="p-2 rounded border text-left {selectedCameraName === cameraName
175
- ? 'border-green-500 bg-green-500/20'
176
  : 'border-slate-600 bg-slate-800/50 hover:bg-slate-700/50'}"
177
  >
178
  <div class="text-sm font-medium">{cameraName}</div>
@@ -192,7 +191,7 @@
192
  Selected Camera: {selectedCameraName}
193
  </p>
194
  <p class="text-xs text-green-400/70">
195
- {localStream ? 'Connected' : 'Not Connected'}
196
  </p>
197
  </div>
198
  {#if !localStream}
@@ -201,10 +200,10 @@
201
  size="sm"
202
  onclick={handleConnectLocalCamera}
203
  disabled={isConnecting}
204
- class="bg-green-600 hover:bg-green-700 text-xs disabled:opacity-50"
205
  >
206
  {#if isConnecting}
207
- <span class="icon-[mdi--loading] animate-spin mr-1 size-3"></span>
208
  Connecting...
209
  {:else}
210
  <span class="icon-[mdi--camera] mr-1 size-3"></span>
@@ -229,12 +228,14 @@
229
  {#if localStream}
230
  <div class="space-y-2">
231
  <div class="text-sm font-medium text-green-300">Live Preview:</div>
232
- <div class="rounded border border-green-500/30 bg-black/50 aspect-video overflow-hidden">
 
 
233
  <video
234
  autoplay
235
  muted
236
  playsinline
237
- class="w-full h-full object-cover"
238
  onloadedmetadata={(e) => {
239
  const video = e.target as HTMLVideoElement;
240
  video.srcObject = localStream;
@@ -257,9 +258,9 @@
257
  <Card.Content>
258
  <div class="space-y-2 text-xs">
259
  {#each Object.entries(compute.sessionData?.camera_room_ids || {}) as [camera, roomId]}
260
- <div class="flex justify-between items-center p-2 rounded bg-slate-800/50">
261
- <span class="text-blue-300 font-medium">{camera}</span>
262
- <span class="text-blue-200 font-mono">{roomId}</span>
263
  </div>
264
  {/each}
265
  </div>
@@ -270,8 +271,9 @@
270
  <!-- Quick Info -->
271
  <div class="rounded border border-slate-700 bg-slate-800/30 p-2 text-xs text-slate-500">
272
  <span class="icon-[mdi--information] mr-1 size-3"></span>
273
- Video inputs stream camera data to the AI model for visual processing. Each camera connects to a dedicated room in the session.
 
274
  </div>
275
  </div>
276
  </Dialog.Content>
277
- </Dialog.Root>
 
1
  <script lang="ts">
2
  import * as Dialog from "@/components/ui/dialog";
3
+ import { video } from "@robothub/transport-server-client";
4
+ import type { video as videoTypes } from "@robothub/transport-server-client";
5
  import { Button } from "@/components/ui/button";
6
  import * as Card from "@/components/ui/card";
7
  import { Badge } from "@/components/ui/badge";
 
19
  let { open = $bindable(), compute, workspaceId }: Props = $props();
20
 
21
  let isConnecting = $state(false);
22
+ let selectedCameraName = $state("front");
23
  let localStream: MediaStream | null = $state(null);
24
  let videoProducer: any = null;
25
 
 
32
 
33
  async function handleConnectLocalCamera() {
34
  if (!compute.hasSession) {
35
+ toast.error("No Inference Session available. Create a session first.");
36
  return;
37
  }
38
 
 
53
 
54
  // Create video producer and connect to the camera room
55
  videoProducer = new video.VideoProducer(settings.transportServerUrl);
56
+
57
  // Connect to the EXISTING camera room (don't create new one)
58
  const participantId = `frontend-camera-${selectedCameraName}-${Date.now()}`;
59
  const success = await videoProducer.connect(workspaceId, cameraRoomId, participantId);
60
+
61
  if (!success) {
62
+ throw new Error("Failed to connect to camera room");
63
  }
64
 
65
  // Start streaming
 
68
  toast.success(`Camera connected to Inference Session`, {
69
  description: `Local camera streaming to ${selectedCameraName} input`
70
  });
 
71
  } catch (error) {
72
+ console.error("Camera connection error:", error);
73
+ toast.error("Failed to connect camera", {
74
+ description: error instanceof Error ? error.message : "Unknown error"
75
  });
76
  } finally {
77
  isConnecting = false;
 
87
  }
88
 
89
  if (localStream) {
90
+ localStream.getTracks().forEach((track) => track.stop());
91
  localStream = null;
92
  }
93
 
94
+ toast.success("Camera disconnected");
95
  } catch (error) {
96
+ console.error("Disconnect error:", error);
97
+ toast.error("Error disconnecting camera");
98
  }
99
  }
100
 
101
  // Cleanup on modal close
102
  $effect(() => {
103
  return () => {
104
+ if (!open) {
105
+ handleDisconnectCamera();
106
+ }
107
+ };
108
+ });
109
  </script>
110
 
111
  <Dialog.Root bind:open>
 
115
  <Dialog.Header class="pb-3">
116
  <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-100">
117
  <span class="icon-[mdi--video-input-component] size-5 text-green-400"></span>
118
+ Video Input - {compute.name || "No Compute Selected"}
119
  </Dialog.Title>
120
  <Dialog.Description class="text-sm text-slate-400">
121
  Connect camera streams to provide visual input for AI inference
 
149
  </Card.Title>
150
  </Card.Header>
151
  <Card.Content class="text-sm text-yellow-300">
152
+ You need to create an Inference Session before connecting video inputs. The session
153
+ defines which camera names are available for connection.
154
  </Card.Content>
155
  </Card.Root>
156
  {:else}
 
169
  <div class="grid grid-cols-2 gap-2">
170
  {#each compute.sessionConfig?.cameraNames || [] as cameraName}
171
  <button
172
+ onclick={() => (selectedCameraName = cameraName)}
173
+ class="rounded border p-2 text-left {selectedCameraName === cameraName
174
+ ? 'border-green-500 bg-green-500/20'
175
  : 'border-slate-600 bg-slate-800/50 hover:bg-slate-700/50'}"
176
  >
177
  <div class="text-sm font-medium">{cameraName}</div>
 
191
  Selected Camera: {selectedCameraName}
192
  </p>
193
  <p class="text-xs text-green-400/70">
194
+ {localStream ? "Connected" : "Not Connected"}
195
  </p>
196
  </div>
197
  {#if !localStream}
 
200
  size="sm"
201
  onclick={handleConnectLocalCamera}
202
  disabled={isConnecting}
203
+ class="bg-green-600 text-xs hover:bg-green-700 disabled:opacity-50"
204
  >
205
  {#if isConnecting}
206
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
207
  Connecting...
208
  {:else}
209
  <span class="icon-[mdi--camera] mr-1 size-3"></span>
 
228
  {#if localStream}
229
  <div class="space-y-2">
230
  <div class="text-sm font-medium text-green-300">Live Preview:</div>
231
+ <div
232
+ class="aspect-video overflow-hidden rounded border border-green-500/30 bg-black/50"
233
+ >
234
  <video
235
  autoplay
236
  muted
237
  playsinline
238
+ class="h-full w-full object-cover"
239
  onloadedmetadata={(e) => {
240
  const video = e.target as HTMLVideoElement;
241
  video.srcObject = localStream;
 
258
  <Card.Content>
259
  <div class="space-y-2 text-xs">
260
  {#each Object.entries(compute.sessionData?.camera_room_ids || {}) as [camera, roomId]}
261
+ <div class="flex items-center justify-between rounded bg-slate-800/50 p-2">
262
+ <span class="font-medium text-blue-300">{camera}</span>
263
+ <span class="font-mono text-blue-200">{roomId}</span>
264
  </div>
265
  {/each}
266
  </div>
 
271
  <!-- Quick Info -->
272
  <div class="rounded border border-slate-700 bg-slate-800/30 p-2 text-xs text-slate-500">
273
  <span class="icon-[mdi--information] mr-1 size-3"></span>
274
+ Video inputs stream camera data to the AI model for visual processing. Each camera connects to
275
+ a dedicated room in the session.
276
  </div>
277
  </div>
278
  </Dialog.Content>
279
+ </Dialog.Root>
src/lib/components/3d/elements/compute/status/ComputeBoxUIKit.svelte CHANGED
@@ -1,13 +1,11 @@
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
- import {
5
- BaseStatusBox,
6
- StatusHeader,
7
- StatusContent,
8
- StatusIndicator
9
  } from "$lib/components/3d/ui";
10
- import { Text } from "threlte-uikit";
11
 
12
  interface Props {
13
  compute: RemoteCompute;
@@ -19,7 +17,6 @@
19
  const computeColor = "rgb(139, 69, 219)";
20
  </script>
21
 
22
-
23
  <BaseStatusBox
24
  minWidth={110}
25
  minHeight={135}
@@ -28,21 +25,20 @@
28
  backgroundOpacity={0.2}
29
  clickable={false}
30
  >
31
- <!-- Header -->
32
- <StatusHeader
33
- icon={ICON["icon-[mdi--brain]"].svg}
34
- text="AI COMPUTE"
35
- color={computeColor}
36
- opacity={0.9}
37
- fontSize={12}
38
- />
39
 
40
- <!-- Compute Info -->
41
- <StatusContent
42
- title={compute.name}
43
- subtitle={compute.statusInfo.statusText}
44
- color="rgb(221, 214, 254)"
45
- variant="primary"
46
- />
47
  </BaseStatusBox>
48
-
 
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent
 
8
  } from "$lib/components/3d/ui";
 
9
 
10
  interface Props {
11
  compute: RemoteCompute;
 
17
  const computeColor = "rgb(139, 69, 219)";
18
  </script>
19
 
 
20
  <BaseStatusBox
21
  minWidth={110}
22
  minHeight={135}
 
25
  backgroundOpacity={0.2}
26
  clickable={false}
27
  >
28
+ <!-- Header -->
29
+ <StatusHeader
30
+ icon={ICON["icon-[mdi--brain]"].svg}
31
+ text="AI COMPUTE"
32
+ color={computeColor}
33
+ opacity={0.9}
34
+ fontSize={12}
35
+ />
36
 
37
+ <!-- Compute Info -->
38
+ <StatusContent
39
+ title={compute.name}
40
+ subtitle={compute.statusInfo.statusText}
41
+ color="rgb(221, 214, 254)"
42
+ variant="primary"
43
+ />
44
  </BaseStatusBox>
 
src/lib/components/3d/elements/compute/status/ComputeConnectionFlowBoxUIKit.svelte CHANGED
@@ -14,19 +14,14 @@
14
  onRobotOutputBoxClick: (compute: RemoteCompute) => void;
15
  }
16
 
17
- let { compute, onVideoInputBoxClick, onRobotInputBoxClick, onRobotOutputBoxClick }: Props = $props();
 
18
 
19
  // Colors
20
  const inputColor = "rgb(34, 197, 94)";
21
  const outputColor = "rgb(59, 130, 246)";
22
  </script>
23
 
24
- <!--
25
- @component
26
- Elegant 2->1->1 connection flow layout for AI compute processing.
27
- Clean vertical stacking of inputs that merge into compute, then flow to output.
28
- -->
29
-
30
  <Container flexDirection="row" alignItems="center" gap={12}>
31
  <!-- Left: Stacked Inputs -->
32
  <Container flexDirection="column" alignItems="center" gap={6}>
@@ -35,11 +30,7 @@ Clean vertical stacking of inputs that merge into compute, then flow to output.
35
  </Container>
36
 
37
  <!-- Arrow: Inputs to Compute -->
38
- <StatusArrow
39
- direction="right"
40
- color={inputColor}
41
- opacity={compute.hasSession ? 1 : 0.5}
42
- />
43
 
44
  <!-- Center: Compute -->
45
  <ComputeBoxUIKit {compute} />
@@ -53,4 +44,4 @@ Clean vertical stacking of inputs that merge into compute, then flow to output.
53
 
54
  <!-- Right: Output -->
55
  <ComputeOutputBoxUIKit {compute} handleClick={() => onRobotOutputBoxClick(compute)} />
56
- </Container>
 
14
  onRobotOutputBoxClick: (compute: RemoteCompute) => void;
15
  }
16
 
17
+ let { compute, onVideoInputBoxClick, onRobotInputBoxClick, onRobotOutputBoxClick }: Props =
18
+ $props();
19
 
20
  // Colors
21
  const inputColor = "rgb(34, 197, 94)";
22
  const outputColor = "rgb(59, 130, 246)";
23
  </script>
24
 
 
 
 
 
 
 
25
  <Container flexDirection="row" alignItems="center" gap={12}>
26
  <!-- Left: Stacked Inputs -->
27
  <Container flexDirection="column" alignItems="center" gap={6}>
 
30
  </Container>
31
 
32
  <!-- Arrow: Inputs to Compute -->
33
+ <StatusArrow direction="right" color={inputColor} opacity={compute.hasSession ? 1 : 0.5} />
 
 
 
 
34
 
35
  <!-- Center: Compute -->
36
  <ComputeBoxUIKit {compute} />
 
44
 
45
  <!-- Right: Output -->
46
  <ComputeOutputBoxUIKit {compute} handleClick={() => onRobotOutputBoxClick(compute)} />
47
+ </Container>
src/lib/components/3d/elements/compute/status/ComputeInputBoxUIKit.svelte CHANGED
@@ -1,14 +1,13 @@
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
- import {
5
- BaseStatusBox,
6
- StatusHeader,
7
- StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
11
- import { Container, SVG, Text } from "threlte-uikit";
12
 
13
  interface Props {
14
  compute: RemoteCompute;
@@ -21,12 +20,6 @@
21
  const inputColor = "rgb(34, 197, 94)";
22
  </script>
23
 
24
- <!--
25
- @component
26
- Compact input box showing the status of video and robot inputs for Inference Sessions.
27
- Displays input connection information when session exists or connection prompt when disconnected.
28
- -->
29
-
30
  <BaseStatusBox
31
  minWidth={120}
32
  minHeight={80}
@@ -67,11 +60,7 @@ Displays input connection information when session exists or connection prompt w
67
  fontSize={12}
68
  />
69
 
70
- <StatusContent
71
- title="Setup Required"
72
- color="rgb(134, 239, 172)"
73
- variant="secondary"
74
- />
75
 
76
  <StatusButton
77
  text="Add Session"
@@ -81,4 +70,4 @@ Displays input connection information when session exists or connection prompt w
81
  textOpacity={0.7}
82
  />
83
  {/if}
84
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
 
11
 
12
  interface Props {
13
  compute: RemoteCompute;
 
20
  const inputColor = "rgb(34, 197, 94)";
21
  </script>
22
 
 
 
 
 
 
 
23
  <BaseStatusBox
24
  minWidth={120}
25
  minHeight={80}
 
60
  fontSize={12}
61
  />
62
 
63
+ <StatusContent title="Setup Required" color="rgb(134, 239, 172)" variant="secondary" />
 
 
 
 
64
 
65
  <StatusButton
66
  text="Add Session"
 
70
  textOpacity={0.7}
71
  />
72
  {/if}
73
+ </BaseStatusBox>
src/lib/components/3d/elements/compute/status/ComputeOutputBoxUIKit.svelte CHANGED
@@ -1,14 +1,13 @@
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
- import {
5
- BaseStatusBox,
6
- StatusHeader,
7
- StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
11
- import { Container, SVG, Text } from "threlte-uikit";
12
 
13
  interface Props {
14
  compute: RemoteCompute;
@@ -19,19 +18,8 @@
19
 
20
  // Output theme color (blue)
21
  const outputColor = "rgb(59, 130, 246)";
22
-
23
- // Icons
24
- // const exportIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTkgMTJsLTcgN3YtNEg1di02aDd2LTR6Ii8+PC9zdmc+";
25
- // const robotIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTIgMTJjMC0yLjIxIDEuNzktNCA0LTRzNCAxLjc5IDQgNC0xLjc5IDQtNCA0LTQtMS43OS00LTR6TTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTAgMThDNy4zIDIwIDMuOCAxNi42IDMuOCAxMlM3LjMgNCA5IDRzNS4yIDMuNCA1LjIgOC02IDgtNSA4eiIvPjwvc3ZnPg==";
26
- // const plusIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTkgMTNoLTZ2NmgtMnYtNkg1di0yaDZWNWgydjZoNnoiLz48L3N2Zz4=";
27
  </script>
28
 
29
- <!--
30
- @component
31
- Compact output box showing the status of robot outputs for Inference Sessions.
32
- Displays output connection information when session exists or connection prompt when disconnected.
33
- -->
34
-
35
  <BaseStatusBox
36
  minWidth={110}
37
  minHeight={135}
@@ -59,8 +47,8 @@ Displays output connection information when session exists or connection prompt
59
  />
60
 
61
  <!-- Status indicator based on running state -->
62
- <StatusIndicator
63
- color={compute.isRunning ? outputColor : "rgb(245, 158, 11)"}
64
  type={compute.isRunning ? "pulse" : "dot"}
65
  />
66
  {:else}
@@ -75,7 +63,7 @@ Displays output connection information when session exists or connection prompt
75
  />
76
 
77
  <StatusContent
78
- title={!compute.hasSession ? 'Need Session' : 'Configure'}
79
  color="rgb(147, 197, 253)"
80
  variant="secondary"
81
  />
@@ -88,4 +76,4 @@ Displays output connection information when session exists or connection prompt
88
  textOpacity={0.7}
89
  />
90
  {/if}
91
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { ICON } from "$lib/utils/icon";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
 
11
 
12
  interface Props {
13
  compute: RemoteCompute;
 
18
 
19
  // Output theme color (blue)
20
  const outputColor = "rgb(59, 130, 246)";
 
 
 
 
 
21
  </script>
22
 
 
 
 
 
 
 
23
  <BaseStatusBox
24
  minWidth={110}
25
  minHeight={135}
 
47
  />
48
 
49
  <!-- Status indicator based on running state -->
50
+ <StatusIndicator
51
+ color={compute.isRunning ? outputColor : "rgb(245, 158, 11)"}
52
  type={compute.isRunning ? "pulse" : "dot"}
53
  />
54
  {:else}
 
63
  />
64
 
65
  <StatusContent
66
+ title={!compute.hasSession ? "Need Session" : "Configure"}
67
  color="rgb(147, 197, 253)"
68
  variant="secondary"
69
  />
 
76
  textOpacity={0.7}
77
  />
78
  {/if}
79
+ </BaseStatusBox>
src/lib/components/3d/elements/compute/status/ComputeStatusBillboard.svelte CHANGED
@@ -7,7 +7,6 @@
7
 
8
  interface Props {
9
  compute: RemoteCompute;
10
- offset?: number;
11
  visible?: boolean;
12
  onVideoInputBoxClick: (compute: RemoteCompute) => void;
13
  onRobotInputBoxClick: (compute: RemoteCompute) => void;
@@ -16,7 +15,6 @@
16
 
17
  let {
18
  compute,
19
- offset = 10,
20
  visible = true,
21
  onVideoInputBoxClick,
22
  onRobotInputBoxClick,
@@ -44,13 +42,13 @@
44
  justifyContent="center"
45
  padding={20}
46
  >
47
- <ComputeConnectionFlowBoxUIKit
48
- {compute}
49
- {onVideoInputBoxClick}
50
- {onRobotInputBoxClick}
51
- {onRobotOutputBoxClick}
52
  />
53
  </Container>
54
  </Root>
55
  </Billboard>
56
- </T.Group>
 
7
 
8
  interface Props {
9
  compute: RemoteCompute;
 
10
  visible?: boolean;
11
  onVideoInputBoxClick: (compute: RemoteCompute) => void;
12
  onRobotInputBoxClick: (compute: RemoteCompute) => void;
 
15
 
16
  let {
17
  compute,
 
18
  visible = true,
19
  onVideoInputBoxClick,
20
  onRobotInputBoxClick,
 
42
  justifyContent="center"
43
  padding={20}
44
  >
45
+ <ComputeConnectionFlowBoxUIKit
46
+ {compute}
47
+ {onVideoInputBoxClick}
48
+ {onRobotInputBoxClick}
49
+ {onRobotOutputBoxClick}
50
  />
51
  </Container>
52
  </Root>
53
  </Billboard>
54
+ </T.Group>
src/lib/components/3d/elements/compute/status/RobotInputBoxUIKit.svelte CHANGED
@@ -1,7 +1,13 @@
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
4
- import { BaseStatusBox, StatusHeader, StatusContent, StatusIndicator, StatusButton } from "$lib/components/3d/ui";
 
 
 
 
 
 
5
 
6
  interface Props {
7
  compute: RemoteCompute;
@@ -10,23 +16,10 @@
10
 
11
  let { compute, handleClick }: Props = $props();
12
 
13
- // Robot theme color (orange - consistent with robot system)
14
  const robotColor = "rgb(245, 158, 11)";
15
-
16
- // Icons
17
- // const robotIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTIgMTJjMC0yLjIxIDEuNzktNCA0LTRzNCAxLjc5IDQgNC0xLjc5IDQtNCA0LTQtMS43OS00LTR6TTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTAgMThDNy4zIDIwIDMuOCAxNi42IDMuOCAxMlM3LjMgNCA5IDRzNS4yIDMuNCA1LjIgOC02IDgtNSA4eiIvPjwvc3ZnPg==";
18
- // const robotOffIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMjAuNzMgMTgsNjYgMy4yN0wyIDQuNzNsMTQuMTQgMTQuMTRDMTcuOSAxNS42MiAxNiAxNy40OSAxNiAxOS44VjIxaDJWMTMuNWgzdjIuNWgydjE1LjJDMjIgMTkuNCAyMi44IDE5IDE0LjE4IDEwLjE4TDEwIDZIMTJWNEgxMlY1aDNjMC0yLjc2IDEuMjQtNSA0LTVzNCAxLjI0IDQgNS0yLjI0IDUtNSA1SDEzdjFIOFY3SDZ2Mm0xIDIuNzNMMjAgMjAuNzNsLS4wMSAwIi8+PC9zdmc+";
19
- // const robotOutlineIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTIgMmMtNS41NyAwLTEwIDQuNDMtMTAgMTBzNC40MyAxMCAxMCAxMCAxMC00LjQzIDEwLTEwUzE3LjU3IDIgMTIgMnptMCAxOGMtNC40MSAwLTgtMy41OS04LThzMy41OS04IDgtOCA4IDMuNTkgOCA4LTMuNTkgOC04IDh6bTAgLTEyYy0yLjIxIDAtNCAuNzktNCA0czEuNzkgNCA0IDQgNC0xLjc5IDQtNC0xLjc5LTQtNC00em0wIDZjLTEuMTEgMC0yLS44OS0yLTJzLjg5LTIgMi0yIDIgLjg5IDIgMi0uODkgMi0yIDJ6Ii8+PC9zdmc+";
20
- // const formatListNumberedIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMiAxN2gybC0xLTFjLjU1IDAgMS0uNDUgMS0xcy0uNDUtMS0xLTEtMSAuNDUtMSAxaC0yYzAtMS4xLjktMiAyLTJzMiAuOSAyIDItLjkgMi0yIDJ6bS0yIDJoNHYySDJabTUtMTJoMTVoMlY5SDdabTAgNGgxNXYySHdtNS0xMEgyTDZ2MmgxdjJIN3ptMC00aDJsLTEtMXYtMmgxVjFINHYyaDJabTAtMUgxWiIvPjwvc3ZnPg==";
21
  </script>
22
 
23
- <!--
24
- @component
25
- Compact robot input box showing the status of robot joint states input for Inference Sessions.
26
- Displays robot connection information when session exists or connection prompt when disconnected.
27
- -->
28
-
29
- <BaseStatusBox
30
  minWidth={100}
31
  minHeight={65}
32
  color={robotColor}
@@ -37,16 +30,16 @@ Displays robot connection information when session exists or connection prompt w
37
  >
38
  {#if compute.hasSession && compute.inputConnections}
39
  <!-- Active Robot Input State -->
40
- <StatusHeader
41
- icon={ICON["icon-[ix--robotic-arm]"].svg}
42
- text="ROBOT"
43
  color={robotColor}
44
  opacity={0.9}
45
  fontSize={11}
46
  />
47
 
48
- <StatusContent
49
- title="Joint States"
50
  subtitle="6 DOF Robot"
51
  color="rgb(254, 215, 170)"
52
  variant="primary"
@@ -56,26 +49,22 @@ Displays robot connection information when session exists or connection prompt w
56
  <StatusIndicator color={robotColor} />
57
  {:else}
58
  <!-- No Session State -->
59
- <StatusHeader
60
- icon={ICON["icon-[ix--robotic-arm]"].svg}
61
- text="NO ROBOT"
62
  color={robotColor}
63
  opacity={0.7}
64
  fontSize={11}
65
  />
66
 
67
- <StatusContent
68
- title="Setup Robot"
69
- color="rgb(254, 215, 170)"
70
- variant="secondary"
71
- />
72
 
73
- <StatusButton
74
- icon={ICON["icon-[mdi--plus]"].svg}
75
  text="Add"
76
  color={robotColor}
77
  backgroundOpacity={0.1}
78
  textOpacity={0.7}
79
  />
80
  {/if}
81
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
+ StatusIndicator,
9
+ StatusButton
10
+ } from "$lib/components/3d/ui";
11
 
12
  interface Props {
13
  compute: RemoteCompute;
 
16
 
17
  let { compute, handleClick }: Props = $props();
18
 
 
19
  const robotColor = "rgb(245, 158, 11)";
 
 
 
 
 
 
20
  </script>
21
 
22
+ <BaseStatusBox
 
 
 
 
 
 
23
  minWidth={100}
24
  minHeight={65}
25
  color={robotColor}
 
30
  >
31
  {#if compute.hasSession && compute.inputConnections}
32
  <!-- Active Robot Input State -->
33
+ <StatusHeader
34
+ icon={ICON["icon-[ix--robotic-arm]"].svg}
35
+ text="ROBOT"
36
  color={robotColor}
37
  opacity={0.9}
38
  fontSize={11}
39
  />
40
 
41
+ <StatusContent
42
+ title="Joint States"
43
  subtitle="6 DOF Robot"
44
  color="rgb(254, 215, 170)"
45
  variant="primary"
 
49
  <StatusIndicator color={robotColor} />
50
  {:else}
51
  <!-- No Session State -->
52
+ <StatusHeader
53
+ icon={ICON["icon-[ix--robotic-arm]"].svg}
54
+ text="NO ROBOT"
55
  color={robotColor}
56
  opacity={0.7}
57
  fontSize={11}
58
  />
59
 
60
+ <StatusContent title="Setup Robot" color="rgb(254, 215, 170)" variant="secondary" />
 
 
 
 
61
 
62
+ <StatusButton
63
+ icon={ICON["icon-[mdi--plus]"].svg}
64
  text="Add"
65
  color={robotColor}
66
  backgroundOpacity={0.1}
67
  textOpacity={0.7}
68
  />
69
  {/if}
70
+ </BaseStatusBox>
src/lib/components/3d/elements/compute/status/RobotOutputBoxUIKit.svelte CHANGED
@@ -10,16 +10,9 @@
10
 
11
  let { compute, handleClick }: Props = $props();
12
 
13
- // Output theme color (blue)
14
  const outputColor = "rgb(59, 130, 246)";
15
  </script>
16
 
17
- <!--
18
- @component
19
- Robot output box showing the status of robot joint commands output from Inference Sessions.
20
- Displays robot command output information when session exists or connection prompt when disconnected.
21
- -->
22
-
23
  <BaseStatusBox
24
  color={outputColor}
25
  borderOpacity={0.6}
 
10
 
11
  let { compute, handleClick }: Props = $props();
12
 
 
13
  const outputColor = "rgb(59, 130, 246)";
14
  </script>
15
 
 
 
 
 
 
 
16
  <BaseStatusBox
17
  color={outputColor}
18
  borderOpacity={0.6}
src/lib/components/3d/elements/compute/status/VideoInputBoxUIKit.svelte CHANGED
@@ -1,8 +1,7 @@
1
  <script lang="ts">
2
- import { Text } from "threlte-uikit";
3
- import { ICON } from "$lib/utils/icon";
4
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
5
  import { BaseStatusBox, StatusHeader, StatusContent, StatusIndicator, StatusButton } from "$lib/components/3d/ui";
 
6
 
7
  interface Props {
8
  compute: RemoteCompute;
@@ -11,22 +10,9 @@
11
 
12
  let { compute, handleClick }: Props = $props();
13
 
14
- // Input theme color (green)
15
  const inputColor = "rgb(34, 197, 94)";
16
-
17
- // Icons
18
- // const videoIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTcgMTAuNVY3YTEgMSAwIDAgMC0xLTFINGExIDEgMCAwIDAtMSAxdjEwYTEgMSAwIDAgMCAxIDFoMTJhMSAxIDAgMCAwIDEtMXYtMy41bDQgNHYtMTF6Ii8+PC9zdmc+";
19
- // const videoOffIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMy4yNyAyTDIgMy4yN2wxLjY4IDEuNjhDMy4yNiA1LjMgMyA1LjY0IDMgNnYxMmMwIC41NS40NSAxIDEgMWg4Yy4zNiAwIC42OC0uMTUuOTItLjM5bDEuNzMgMS43M0wxNiAyMC43M0wxOC43MyAxOGwtLjg5LS44OUwyMCAxNXYtMWwtNC05VjVjMC0uNTUtLjQ1LTEtMS0xSDlsLTEuMTYtMS4xNkMxMS41NSAyIDEwIDQuMjcgMTAgNHYxTDMuMjcgMnoiLz48L3N2Zz4=";
20
- // const videoPlusIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTMgMTRoMXYzaDN2MWgtM3YzaC0xdi0zaC0zdi0xaDN2LTN6bTUgMGwtNS0zdjZsNS0zek0xMSAzSDNDMS45IDMgMSAzLjkgMSA1djEwYzAgMS4xLjkgMiAyIDJoOGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yeiIvPjwvc3ZnPg==";
21
- // const cameraMultipleIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNCAyaDZsMS41IDEuNUg2djJoOGwtLjUtLjVIMThsMS41IDEuNWgzYzEuMSAwIDIgLjkgMiAydjEwYzAgMS4xLS45IDItMiAySDZjLTEuMSAwLTItLjktMi0yVjRjMC0xLjEuOS0yIDItMnptNSA2Yy0xLjExIDAtMi4wOC45LTIuMDggMnMuOTcgMiAyLjA4IDIgMi4wOC0uOSAyLjA4LTItLjk3LTItMi4wOC0yeiIvPjwvc3ZnPg==";
22
  </script>
23
 
24
- <!--
25
- @component
26
- Compact video input box showing the status of camera video streams for Inference Sessions.
27
- Displays video connection information when session exists or connection prompt when disconnected.
28
- -->
29
-
30
  <BaseStatusBox
31
  minWidth={100}
32
  minHeight={65}
 
1
  <script lang="ts">
 
 
2
  import type { RemoteCompute } from "$lib/elements/compute//RemoteCompute.svelte";
3
  import { BaseStatusBox, StatusHeader, StatusContent, StatusIndicator, StatusButton } from "$lib/components/3d/ui";
4
+ import { ICON } from "@/utils/icon";
5
 
6
  interface Props {
7
  compute: RemoteCompute;
 
10
 
11
  let { compute, handleClick }: Props = $props();
12
 
 
13
  const inputColor = "rgb(34, 197, 94)";
 
 
 
 
 
 
14
  </script>
15
 
 
 
 
 
 
 
16
  <BaseStatusBox
17
  minWidth={100}
18
  minHeight={65}
src/lib/components/3d/elements/robot/RobotGridItem.svelte CHANGED
@@ -8,11 +8,11 @@
8
  import { createUrdfRobot } from "$lib/elements/robot/createRobot.svelte";
9
  import { interactivity, type IntersectionEvent, useCursor } from "@threlte/extras";
10
  import type { RobotUrdfConfig } from "$lib/types/urdf";
11
- import { onMount } from 'svelte';
12
- import type IUrdfRobot from '@/components/3d/elements/robot/URDF/interfaces/IUrdfRobot.js';
13
- import { ROBOT_CONFIG } from '$lib/elements/robot/config.js';
14
 
15
- interface Props {
16
  robot: Robot;
17
  onCameraMove: (ref: any) => void;
18
  onInputBoxClick: (robot: Robot) => void;
@@ -22,7 +22,13 @@
22
 
23
  let ref = $state<Group | undefined>(undefined);
24
 
25
- let { robot = $bindable(), onCameraMove, onInputBoxClick, onRobotBoxClick, onOutputBoxClick }: Props = $props();
 
 
 
 
 
 
26
 
27
  let urdfRobotState = $state<IUrdfRobot | null>(null);
28
  let lastJointValues = $state<Record<string, number>>({});
@@ -31,12 +37,12 @@
31
  const urdfConfig: RobotUrdfConfig = {
32
  urdfUrl: "/robots/so-100/so_arm100.urdf"
33
  };
34
-
35
  try {
36
  const UrdfRobotState = await createUrdfRobot(urdfConfig);
37
  urdfRobotState = UrdfRobotState.urdfRobot;
38
  } catch (error) {
39
- console.error('Failed to load URDF robot:', error);
40
  }
41
  });
42
 
@@ -47,17 +53,19 @@
47
 
48
  // Check if this is the initial sync (no previous values recorded)
49
  const isInitialSync = Object.keys(lastJointValues).length === 0;
50
-
51
  // Check if any joint values have actually changed (using config threshold)
52
  const threshold = isInitialSync ? 0 : ROBOT_CONFIG.performance.uiUpdateThreshold;
53
- const hasSignificantChanges = isInitialSync || robot.jointArray.some(joint =>
54
- Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold
55
- );
 
 
56
  if (!hasSignificantChanges) return;
57
 
58
  // Batch update all joints that have changed (or all joints on initial sync)
59
  let updatedCount = 0;
60
- robot.jointArray.forEach(joint => {
61
  if (isInitialSync || Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold) {
62
  lastJointValues[joint.name] = joint.value;
63
  const urdfJoint = findUrdfJoint(urdfRobotState, joint.name);
@@ -66,11 +74,11 @@
66
  if (!urdfJoint.rotation) {
67
  urdfJoint.rotation = [0, 0, 0];
68
  }
69
-
70
  // Use the Robot's conversion method for proper coordinate mapping
71
  const radians = robot.convertNormalizedToUrdfRadians(joint.name, joint.value);
72
  const axis = urdfJoint.axis_xyz || [0, 0, 1];
73
-
74
  // Reset rotation and apply to the appropriate axis
75
  urdfJoint.rotation = [0, 0, 0];
76
  for (let i = 0; i < 3; i++) {
@@ -105,7 +113,6 @@
105
  event.stopPropagation();
106
  isToggled = !isToggled;
107
  }
108
-
109
  </script>
110
 
111
  <T.Group
@@ -116,11 +123,7 @@
116
  scale={[10, 10, 10]}
117
  rotation={[-Math.PI / 2, 0, 0]}
118
  >
119
- <T.Group
120
- onpointerenter={onPointerEnter}
121
- onpointerleave={onPointerLeave}
122
- onclick={handleClick}
123
- >
124
  {#if urdfRobotState}
125
  {#each getRootLinks(urdfRobotState) as link}
126
  <UrdfLink
@@ -148,7 +151,7 @@
148
  <!-- Fallback simple representation while URDF loads -->
149
  <T.Mesh>
150
  <T.BoxGeometry args={[0.1, 0.1, 0.1]} />
151
- <T.MeshStandardMaterial
152
  color={robot.connectionStatus.isConnected ? "#10b981" : "#6b7280"}
153
  opacity={$hovering ? 0.8 : 1.0}
154
  transparent
@@ -157,13 +160,11 @@
157
  {/if}
158
  </T.Group>
159
 
160
-
161
- <RobotStatusBillboard
162
- {robot}
163
- onInputBoxClick={onInputBoxClick}
164
- onRobotBoxClick={onRobotBoxClick}
165
- onOutputBoxClick={onOutputBoxClick}
166
- visible={isToggled}
167
  />
168
-
169
  </T.Group>
 
8
  import { createUrdfRobot } from "$lib/elements/robot/createRobot.svelte";
9
  import { interactivity, type IntersectionEvent, useCursor } from "@threlte/extras";
10
  import type { RobotUrdfConfig } from "$lib/types/urdf";
11
+ import { onMount } from "svelte";
12
+ import type IUrdfRobot from "@/components/3d/elements/robot/URDF/interfaces/IUrdfRobot.js";
13
+ import { ROBOT_CONFIG } from "$lib/elements/robot/config.js";
14
 
15
+ interface Props {
16
  robot: Robot;
17
  onCameraMove: (ref: any) => void;
18
  onInputBoxClick: (robot: Robot) => void;
 
22
 
23
  let ref = $state<Group | undefined>(undefined);
24
 
25
+ let {
26
+ robot = $bindable(),
27
+ onCameraMove,
28
+ onInputBoxClick,
29
+ onRobotBoxClick,
30
+ onOutputBoxClick
31
+ }: Props = $props();
32
 
33
  let urdfRobotState = $state<IUrdfRobot | null>(null);
34
  let lastJointValues = $state<Record<string, number>>({});
 
37
  const urdfConfig: RobotUrdfConfig = {
38
  urdfUrl: "/robots/so-100/so_arm100.urdf"
39
  };
40
+
41
  try {
42
  const UrdfRobotState = await createUrdfRobot(urdfConfig);
43
  urdfRobotState = UrdfRobotState.urdfRobot;
44
  } catch (error) {
45
+ console.error("Failed to load URDF robot:", error);
46
  }
47
  });
48
 
 
53
 
54
  // Check if this is the initial sync (no previous values recorded)
55
  const isInitialSync = Object.keys(lastJointValues).length === 0;
56
+
57
  // Check if any joint values have actually changed (using config threshold)
58
  const threshold = isInitialSync ? 0 : ROBOT_CONFIG.performance.uiUpdateThreshold;
59
+ const hasSignificantChanges =
60
+ isInitialSync ||
61
+ robot.jointArray.some(
62
+ (joint) => Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold
63
+ );
64
  if (!hasSignificantChanges) return;
65
 
66
  // Batch update all joints that have changed (or all joints on initial sync)
67
  let updatedCount = 0;
68
+ robot.jointArray.forEach((joint) => {
69
  if (isInitialSync || Math.abs((lastJointValues[joint.name] || 0) - joint.value) > threshold) {
70
  lastJointValues[joint.name] = joint.value;
71
  const urdfJoint = findUrdfJoint(urdfRobotState, joint.name);
 
74
  if (!urdfJoint.rotation) {
75
  urdfJoint.rotation = [0, 0, 0];
76
  }
77
+
78
  // Use the Robot's conversion method for proper coordinate mapping
79
  const radians = robot.convertNormalizedToUrdfRadians(joint.name, joint.value);
80
  const axis = urdfJoint.axis_xyz || [0, 0, 1];
81
+
82
  // Reset rotation and apply to the appropriate axis
83
  urdfJoint.rotation = [0, 0, 0];
84
  for (let i = 0; i < 3; i++) {
 
113
  event.stopPropagation();
114
  isToggled = !isToggled;
115
  }
 
116
  </script>
117
 
118
  <T.Group
 
123
  scale={[10, 10, 10]}
124
  rotation={[-Math.PI / 2, 0, 0]}
125
  >
126
+ <T.Group onpointerenter={onPointerEnter} onpointerleave={onPointerLeave} onclick={handleClick}>
 
 
 
 
127
  {#if urdfRobotState}
128
  {#each getRootLinks(urdfRobotState) as link}
129
  <UrdfLink
 
151
  <!-- Fallback simple representation while URDF loads -->
152
  <T.Mesh>
153
  <T.BoxGeometry args={[0.1, 0.1, 0.1]} />
154
+ <T.MeshStandardMaterial
155
  color={robot.connectionStatus.isConnected ? "#10b981" : "#6b7280"}
156
  opacity={$hovering ? 0.8 : 1.0}
157
  transparent
 
160
  {/if}
161
  </T.Group>
162
 
163
+ <RobotStatusBillboard
164
+ {robot}
165
+ {onInputBoxClick}
166
+ {onRobotBoxClick}
167
+ {onOutputBoxClick}
168
+ visible={isToggled}
 
169
  />
 
170
  </T.Group>
src/lib/components/3d/elements/robot/Robots.svelte CHANGED
@@ -11,7 +11,7 @@
11
  interface Props {
12
  workspaceId: string;
13
  }
14
- let {workspaceId}: Props = $props();
15
 
16
  let isInputModalOpen = $state(false);
17
  let isOutputModalOpen = $state(false);
@@ -22,12 +22,12 @@
22
  selectedRobot = robot;
23
  isInputModalOpen = true;
24
  }
25
-
26
  function onRobotBoxClick(robot: Robot) {
27
  selectedRobot = robot;
28
  isManualControlSheetOpen = true;
29
  }
30
-
31
  function onOutputBoxClick(robot: Robot) {
32
  selectedRobot = robot;
33
  isOutputModalOpen = true;
@@ -43,7 +43,7 @@
43
  z: 0
44
  });
45
  } catch (error) {
46
- console.error('Failed to create robot:', error);
47
  }
48
  }
49
 
@@ -54,12 +54,15 @@
54
 
55
  onDestroy(() => {
56
  // Clean up robots and unlock servos for safety
57
- console.log('🧹 Cleaning up robots and unlocking servos...');
58
- robotManager.destroy().then(() => {
59
- console.log('✅ Cleanup completed successfully');
60
- }).catch((error) => {
61
- console.error('❌ Error during cleanup:', error);
62
- });
 
 
 
63
  });
64
  </script>
65
 
@@ -67,9 +70,9 @@
67
  <RobotGridItem
68
  {robot}
69
  onCameraMove={() => {}}
70
- onInputBoxClick={onInputBoxClick}
71
- onRobotBoxClick={onRobotBoxClick}
72
- onOutputBoxClick={onOutputBoxClick}
73
  />
74
  {/each}
75
 
 
11
  interface Props {
12
  workspaceId: string;
13
  }
14
+ let { workspaceId }: Props = $props();
15
 
16
  let isInputModalOpen = $state(false);
17
  let isOutputModalOpen = $state(false);
 
22
  selectedRobot = robot;
23
  isInputModalOpen = true;
24
  }
25
+
26
  function onRobotBoxClick(robot: Robot) {
27
  selectedRobot = robot;
28
  isManualControlSheetOpen = true;
29
  }
30
+
31
  function onOutputBoxClick(robot: Robot) {
32
  selectedRobot = robot;
33
  isOutputModalOpen = true;
 
43
  z: 0
44
  });
45
  } catch (error) {
46
+ console.error("Failed to create robot:", error);
47
  }
48
  }
49
 
 
54
 
55
  onDestroy(() => {
56
  // Clean up robots and unlock servos for safety
57
+ console.log("🧹 Cleaning up robots and unlocking servos...");
58
+ robotManager
59
+ .destroy()
60
+ .then(() => {
61
+ console.log("✅ Cleanup completed successfully");
62
+ })
63
+ .catch((error) => {
64
+ console.error("❌ Error during cleanup:", error);
65
+ });
66
  });
67
  </script>
68
 
 
70
  <RobotGridItem
71
  {robot}
72
  onCameraMove={() => {}}
73
+ {onInputBoxClick}
74
+ {onRobotBoxClick}
75
+ {onOutputBoxClick}
76
  />
77
  {/each}
78
 
src/lib/components/3d/elements/robot/URDF/primitives/UrdfJoint.svelte CHANGED
@@ -6,8 +6,6 @@
6
  import UrdfLink from "./UrdfLink.svelte";
7
  import { Vector3 } from "three";
8
  import { Billboard, MeshLineGeometry, Text } from "@threlte/extras";
9
-
10
- import type IUrdfLink from "../interfaces/IUrdfLink";
11
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
12
  import { radiansToDegrees } from "@/utils";
13
 
 
6
  import UrdfLink from "./UrdfLink.svelte";
7
  import { Vector3 } from "three";
8
  import { Billboard, MeshLineGeometry, Text } from "@threlte/extras";
 
 
9
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
10
  import { radiansToDegrees } from "@/utils";
11
 
src/lib/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte CHANGED
@@ -7,9 +7,7 @@
7
  import { getChildJoints } from "../utils/UrdfParser";
8
  import UrdfJoint from "./UrdfJoint.svelte";
9
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
10
- import type IUrdfJoint from "../interfaces/IUrdfJoint";
11
  import { T } from "@threlte/core";
12
- import { Billboard, HTML, Portal } from "@threlte/extras";
13
  // import Pointcloud from "@/components/test/Pointcloud.svelte";
14
 
15
  interface Props {
@@ -55,10 +53,6 @@
55
  }: Props = $props();
56
 
57
  let showPointCloud = false;
58
-
59
- $effect(() => {
60
- console.log("render");
61
- });
62
  </script>
63
 
64
  {@html `<!-- Link ${link.name} -->`}
 
7
  import { getChildJoints } from "../utils/UrdfParser";
8
  import UrdfJoint from "./UrdfJoint.svelte";
9
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
 
10
  import { T } from "@threlte/core";
 
11
  // import Pointcloud from "@/components/test/Pointcloud.svelte";
12
 
13
  interface Props {
 
53
  }: Props = $props();
54
 
55
  let showPointCloud = false;
 
 
 
 
56
  </script>
57
 
58
  {@html `<!-- Link ${link.name} -->`}
src/lib/components/3d/elements/robot/URDF/primitives/UrdfThree.svelte CHANGED
@@ -5,9 +5,6 @@
5
  import { T } from "@threlte/core";
6
  import { getRootLinks } from "../utils/UrdfParser";
7
  import UrdfLink from "./UrdfLink.svelte";
8
- import { scale } from "svelte/transition";
9
- import type IUrdfLink from "../interfaces/IUrdfLink";
10
- import type IUrdfJoint from "../interfaces/IUrdfJoint";
11
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
12
 
13
  interface Props {
@@ -42,15 +39,8 @@
42
  jointColor = "#000000",
43
  jointIndicatorColor = "#000000",
44
  nameHeight = 0.1,
45
- textScale = 1,
46
  }: Props = $props();
47
-
48
- // Three.js object management
49
- $effect(() => {
50
- if (ref) {
51
- // Handle three.js object updates
52
- }
53
- });
54
  </script>
55
 
56
  <T.Group {position} {quaternion} scale={[10, 10, 10]} rotation={[-Math.PI / 2, 0, 0]}>
 
5
  import { T } from "@threlte/core";
6
  import { getRootLinks } from "../utils/UrdfParser";
7
  import UrdfLink from "./UrdfLink.svelte";
 
 
 
8
  import type IUrdfRobot from "../interfaces/IUrdfRobot";
9
 
10
  interface Props {
 
39
  jointColor = "#000000",
40
  jointIndicatorColor = "#000000",
41
  nameHeight = 0.1,
42
+ textScale = 1
43
  }: Props = $props();
 
 
 
 
 
 
 
44
  </script>
45
 
46
  <T.Group {position} {quaternion} scale={[10, 10, 10]} rotation={[-Math.PI / 2, 0, 0]}>
src/lib/components/3d/elements/robot/URDF/primitives/UrdfVisual.svelte CHANGED
@@ -36,10 +36,6 @@
36
  const baseColor = visual?.color_rgba
37
  ? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
38
  : defaultColor;
39
-
40
- $effect(() => {
41
- console.log("render");
42
- });
43
  </script>
44
 
45
  {#if visual.type === "mesh"}
 
36
  const baseColor = visual?.color_rgba
37
  ? numberArrayToColor(visual.color_rgba.slice(0, 3) as [number, number, number])
38
  : defaultColor;
 
 
 
 
39
  </script>
40
 
41
  {#if visual.type === "mesh"}
src/lib/components/3d/elements/robot/URDF/utils/UrdfParser.ts CHANGED
@@ -20,7 +20,7 @@ export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
20
  // Compute the links
21
  const links: IUrdfLink[] = [];
22
  const joints = robot.joints;
23
-
24
  for (const link of Object.values(robot.links)) {
25
  let isRoot = true;
26
  for (const joint of joints) {
@@ -35,7 +35,7 @@ export function getRootLinks(robot: IUrdfRobot): IUrdfLink[] {
35
  }
36
  }
37
 
38
-
39
 
40
  return links;
41
  }
 
20
  // Compute the links
21
  const links: IUrdfLink[] = [];
22
  const joints = robot.joints;
23
+
24
  for (const link of Object.values(robot.links)) {
25
  let isRoot = true;
26
  for (const joint of joints) {
 
35
  }
36
  }
37
 
38
+
39
 
40
  return links;
41
  }
src/lib/components/3d/elements/robot/modal/InputConnectionModal.svelte CHANGED
@@ -4,14 +4,12 @@
4
  import * as Card from "@/components/ui/card";
5
  import * as Alert from "@/components/ui/alert";
6
  import { Badge } from "@/components/ui/badge";
7
- import { Separator } from "@/components/ui/separator";
8
  import { toast } from "svelte-sonner";
9
 
10
  import type { Robot } from "$lib/elements/robot/Robot.svelte.js";
11
  import { robotManager } from "$lib/elements/robot/RobotManager.svelte.js";
12
- import { settings } from "$lib/runes/settings.svelte";
13
  import USBCalibrationPanel from "$lib/elements/robot/calibration/USBCalibrationPanel.svelte";
14
-
15
  interface Props {
16
  workspaceId: string;
17
  open: boolean;
@@ -23,9 +21,9 @@
23
  let isConnecting = $state(false);
24
  let error = $state<string | null>(null);
25
  let showUSBCalibration = $state(false);
26
- let pendingUSBConnection: 'input' | null = $state(null);
27
- let selectedRoomId = $state('');
28
- let customRoomId = $state('');
29
  let showRoomManagement = $state(true);
30
  let hasLoadedRooms = $state(false);
31
 
@@ -35,7 +33,7 @@
35
  refreshRooms();
36
  hasLoadedRooms = true;
37
  }
38
-
39
  // Reset when modal closes
40
  if (!open) {
41
  hasLoadedRooms = false;
@@ -55,7 +53,7 @@
55
  error = null;
56
  await robotManager.refreshRooms(workspaceId);
57
  } catch (err) {
58
- error = err instanceof Error ? err.message : 'Failed to refresh rooms';
59
  }
60
  }
61
 
@@ -65,18 +63,18 @@
65
  error = null;
66
  const roomId = customRoomId.trim() || robot.id;
67
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
68
-
69
  if (result.success) {
70
- customRoomId = '';
71
  await refreshRooms();
72
  toast.success("Room Created", {
73
  description: `Successfully created room ${result.roomId}`
74
  });
75
  } else {
76
- error = result.error || 'Failed to create room';
77
  }
78
  } catch (err) {
79
- error = err instanceof Error ? err.message : 'Failed to create room';
80
  } finally {
81
  isConnecting = false;
82
  }
@@ -84,10 +82,10 @@
84
 
85
  async function joinRoomAsInput() {
86
  if (!selectedRoomId) {
87
- error = 'Please select a room';
88
  return;
89
  }
90
-
91
  try {
92
  isConnecting = true;
93
  error = null;
@@ -96,7 +94,7 @@
96
  description: `Successfully joined room ${selectedRoomId} - now receiving commands`
97
  });
98
  } catch (err) {
99
- error = err instanceof Error ? err.message : 'Failed to join room as input';
100
  } finally {
101
  isConnecting = false;
102
  }
@@ -108,19 +106,19 @@
108
  error = null;
109
  const roomId = customRoomId.trim() || robot.id;
110
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
111
-
112
  if (result.success) {
113
  await robotManager.connectConsumerToRoom(workspaceId, robot.id, result.roomId!);
114
- customRoomId = '';
115
  await refreshRooms();
116
  toast.success("Room Created & Joined", {
117
  description: `Successfully created and joined room ${result.roomId} - ready to receive commands`
118
  });
119
  } else {
120
- error = result.error || 'Failed to create room and join as input';
121
  }
122
  } catch (err) {
123
- error = err instanceof Error ? err.message : 'Failed to create room and join as input';
124
  } finally {
125
  isConnecting = false;
126
  }
@@ -132,21 +130,21 @@
132
  error = null;
133
 
134
  if (robot.calibrationManager.needsCalibration) {
135
- pendingUSBConnection = 'input';
136
  showUSBCalibration = true;
137
  return;
138
  }
139
 
140
  await robot.setConsumer({
141
- type: 'usb',
142
  baudRate: 1000000
143
  });
144
-
145
  toast.success("USB Input Connected", {
146
  description: "Successfully connected to physical robot hardware"
147
  });
148
  } catch (err) {
149
- error = err instanceof Error ? err.message : 'Unknown error';
150
  toast.error("Failed to Connect USB Input", {
151
  description: `Could not connect to robot hardware: ${error}`
152
  });
@@ -160,12 +158,12 @@
160
  isConnecting = true;
161
  error = null;
162
  await robot.removeConsumer();
163
-
164
  toast.success("Input Disconnected", {
165
  description: "Successfully disconnected input source"
166
  });
167
  } catch (err) {
168
- error = err instanceof Error ? err.message : 'Unknown error';
169
  toast.error("Failed to Disconnect Input", {
170
  description: `Could not disconnect input: ${error}`
171
  });
@@ -176,11 +174,11 @@
176
 
177
  async function onCalibrationComplete() {
178
  showUSBCalibration = false;
179
-
180
- if (pendingUSBConnection === 'input') {
181
  await connectUSBInput();
182
  }
183
-
184
  pendingUSBConnection = null;
185
  }
186
 
@@ -196,12 +194,16 @@
196
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
197
  >
198
  <Dialog.Header class="pb-3">
199
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
200
- <span class="icon-[mdi--account-supervisor] size-5 text-green-500 dark:text-green-400"></span>
 
 
 
201
  Input Connection - Robot {robot.id}
202
  </Dialog.Title>
203
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
204
- Configure how this robot receives commands. Choose between direct hardware control or remote collaboration.
 
205
  </Dialog.Description>
206
  </Dialog.Header>
207
 
@@ -209,283 +211,334 @@
209
  <div class="space-y-4 pb-4">
210
  <!-- Error display -->
211
  {#if error}
212
- <Alert.Root class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20">
 
 
213
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
214
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
215
- <Alert.Description class="text-red-600 text-sm dark:text-red-400">
216
  {error}
217
  </Alert.Description>
218
  </Alert.Root>
219
  {/if}
220
 
221
- <!-- USB Calibration Panel -->
222
- {#if showUSBCalibration}
223
- <Card.Root class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20">
224
- <Card.Header>
225
- <div class="flex justify-between items-center">
226
- <Card.Title class="text-lg font-semibold text-orange-700 dark:text-orange-200">
227
- Hardware Calibration Required
228
- </Card.Title>
229
- <button
230
- onclick={onCalibrationCancel}
231
- class="text-slate-600 hover:text-slate-900 dark:text-gray-400 dark:hover:text-white"
 
 
 
 
 
 
 
 
 
 
232
  >
233
-
234
- </button>
235
- </div>
236
- </Card.Header>
237
- <Card.Content class="space-y-4">
238
- <Alert.Root class="border-orange-300/30 bg-orange-100/10 dark:border-orange-500/30 dark:bg-orange-500/10">
239
- <span class="icon-[mdi--information] size-4 text-orange-500 dark:text-orange-400"></span>
240
- <Alert.Description class="text-orange-700 text-sm dark:text-orange-200">
241
- Before connecting to the physical robot, calibration is required to map the servo positions to software values. This ensures accurate control.
242
- </Alert.Description>
243
- </Alert.Root>
244
-
245
- <USBCalibrationPanel
246
- calibrationManager={robot.calibrationManager}
247
- connectionType="consumer"
248
- {onCalibrationComplete}
249
- onCancel={onCalibrationCancel}
250
- />
251
- </Card.Content>
252
- </Card.Root>
253
- {:else}
254
-
255
- <!-- Current Status Overview -->
256
- <Card.Root class="border-green-300/30 bg-green-100/20 dark:border-green-500/30 dark:bg-green-900/20">
257
- <Card.Content class="p-4">
258
- <div class="flex items-center justify-between">
259
- <div class="flex items-center gap-2">
260
- <span class="icon-[mdi--connection] size-4 text-green-500 dark:text-green-400"></span>
261
- <span class="text-sm font-medium text-green-700 dark:text-green-300">Current Input Source</span>
262
- </div>
263
- {#if robot.hasConsumer}
264
- <Badge variant="default" class="bg-green-500 text-xs dark:bg-green-600">
265
- {robot.consumer?.name || 'Connected'}
266
- </Badge>
267
- {:else}
268
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Input Connected</Badge>
269
- {/if}
270
- </div>
271
- {#if robot.hasConsumer}
272
- <div class="mt-2 text-xs text-green-600/70 dark:text-green-400/70">
273
- Status: {robot.consumer?.status.isConnected ? 'Connected' : 'Disconnected'}
274
- </div>
275
- {/if}
276
- </Card.Content>
277
- </Card.Root>
278
-
279
- <!-- Local Hardware Connection -->
280
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
281
- <Card.Header>
282
- <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
283
- <span class="icon-[mdi--usb-port] size-4"></span>
284
- Local Hardware (USB)
285
- </Card.Title>
286
- <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
287
- Read physical robot movements in real-time
288
- </Card.Description>
289
- </Card.Header>
290
- <Card.Content class="space-y-3">
291
- {#if robot.hasConsumer && robot.consumer?.name === 'USB Consumer'}
292
- <!-- USB Connected State -->
293
- <div class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20">
294
  <div class="flex items-center justify-between">
295
- <div>
296
- <p class="text-sm font-medium text-blue-700 dark:text-blue-300">Hardware Connected</p>
297
- <p class="text-xs text-blue-600/70 dark:text-blue-400/70">Reading physical servo positions</p>
 
 
 
298
  </div>
299
- <Button
300
- variant="destructive"
301
- size="sm"
302
- onclick={disconnectInput}
303
- disabled={isConnecting}
304
- class="h-7 px-2 text-xs"
305
- >
306
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
307
- {isConnecting ? 'Disconnecting...' : 'Disconnect'}
308
- </Button>
309
  </div>
310
- </div>
311
- {:else}
312
- <!-- USB Connection Button -->
313
- <Button
314
- variant="secondary"
315
- onclick={connectUSBInput}
316
- disabled={isConnecting || robot.hasConsumer}
317
- class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
318
- >
319
- <span class="icon-[mdi--usb] mr-2 size-4"></span>
320
- {isConnecting ? 'Connecting...' : 'Connect to Hardware'}
321
- </Button>
322
-
323
- {#if robot.hasConsumer}
324
- <p class="text-xs text-slate-600 dark:text-slate-500">
325
- Disconnect current input to connect USB hardware
326
- </p>
327
- {/if}
328
- {/if}
329
- </Card.Content>
330
- </Card.Root>
331
-
332
- <!-- Remote Collaboration -->
333
- <Card.Root class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5">
334
- <Card.Header>
335
- <div class="flex items-center justify-between">
336
- <div>
337
- <Card.Title class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200">
338
- <span class="icon-[mdi--cloud-sync] size-4"></span>
339
- Remote Collaboration (Rooms)
340
  </Card.Title>
341
- <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
342
- Receive commands from AI systems, remote users, or other software
343
  </Card.Description>
344
- </div>
345
- <Button
346
- variant="ghost"
347
- size="sm"
348
- onclick={refreshRooms}
349
- disabled={robotManager.roomsLoading || isConnecting}
350
- class="h-7 px-2 text-xs text-purple-700 hover:text-purple-800 hover:bg-purple-200/20 dark:text-purple-300 dark:hover:text-purple-200 dark:hover:bg-purple-500/20"
351
- >
352
- {#if robotManager.roomsLoading}
353
- <span class="icon-[mdi--loading] animate-spin size-3 mr-1"></span>
354
- Refreshing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  {:else}
356
- <span class="icon-[mdi--refresh] size-3 mr-1"></span>
357
- Refresh
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  {/if}
359
- </Button>
360
- </div>
361
- </Card.Header>
362
- <Card.Content class="space-y-4">
363
- {#if robot.hasConsumer && robot.consumer?.name?.includes('Remote Consumer')}
364
- <!-- Remote Connected State -->
365
- <div class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20">
 
366
  <div class="flex items-center justify-between">
367
  <div>
368
- <p class="text-sm font-medium text-purple-700 dark:text-purple-300">Room Connected</p>
369
- <p class="text-xs text-purple-600/70 dark:text-purple-400/70">Receiving remote commands</p>
 
 
 
 
 
 
 
370
  </div>
371
  <Button
372
- variant="destructive"
373
  size="sm"
374
- onclick={disconnectInput}
375
- disabled={isConnecting}
376
- class="h-7 px-2 text-xs"
377
  >
378
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
379
- {isConnecting ? 'Leaving...' : 'Leave Room'}
 
 
 
 
 
380
  </Button>
381
  </div>
382
- </div>
383
- {:else}
384
- <!-- Create New Room -->
385
- <div class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5">
386
- <div class="space-y-2">
387
- <div class="flex items-center gap-2">
388
- <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"></span>
389
- <p class="text-sm font-medium text-green-700 dark:text-green-300">Create New Room</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  </div>
391
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
392
- Create a room where others can send commands to this robot
393
- </p>
394
- <input
395
- bind:value={customRoomId}
396
- placeholder={`Room ID (default: ${robot.id})`}
397
- disabled={isConnecting || robot.hasConsumer}
398
- class="w-full px-2 py-1 bg-slate-50 border border-slate-300 rounded text-xs text-slate-900 disabled:opacity-50 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
399
- />
400
- <div class="flex gap-1">
401
- <Button
402
- variant="secondary"
403
- size="sm"
404
- onclick={createRoom}
405
- disabled={isConnecting || robot.hasConsumer}
406
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
407
- >
408
- Create Only
409
- </Button>
410
- <Button
411
- variant="secondary"
412
- size="sm"
413
- onclick={createRoomAndJoinAsInput}
414
- disabled={isConnecting || robot.hasConsumer}
415
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
416
- >
417
- Create & Join as Input
418
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  </div>
420
- </div>
421
- </div>
422
 
423
- <!-- Existing Rooms -->
424
- <div class="space-y-2">
425
- <div class="flex items-center justify-between">
426
- <span class="text-xs font-medium text-purple-700 dark:text-purple-300">Join Existing Room:</span>
427
- <span class="text-xs text-slate-600 dark:text-slate-400">
428
- {robotManager.rooms.length} room{robotManager.rooms.length !== 1 ? 's' : ''} available
429
- </span>
430
- </div>
431
-
432
- <div class="max-h-40 space-y-2 overflow-y-auto">
433
- {#if robotManager.rooms.length === 0}
434
- <div class="text-center py-3 text-xs text-slate-600 dark:text-slate-400">
435
- {robotManager.roomsLoading ? 'Loading rooms...' : 'No rooms available. Create one to get started.'}
436
  </div>
437
- {:else}
438
- {#each robotManager.rooms as room}
439
- <div class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50">
440
- <div class="flex items-start justify-between gap-3">
441
- <div class="flex-1 min-w-0">
442
- <p class="text-xs font-medium text-slate-800 truncate dark:text-slate-200">
443
- {room.id}
444
- </p>
445
- <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
446
- <span>{room.has_producer ? '📤 Has Output' : '📥 No Output'}</span>
447
- <span>👥 {room.participants?.total || 0} users</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  </div>
449
  </div>
450
- <Button
451
- variant="secondary"
452
- size="sm"
453
- onclick={() => {
454
- selectedRoomId = room.id;
455
- joinRoomAsInput();
456
- }}
457
- disabled={isConnecting || robot.hasConsumer}
458
- class="h-6 px-2 text-xs bg-purple-500 hover:bg-purple-600 shrink-0 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
459
- >
460
- <span class="icon-[mdi--login] mr-1 size-3"></span>
461
- Join as Input
462
- </Button>
463
- </div>
464
- </div>
465
- {/each}
466
  {/if}
467
- </div>
468
- </div>
469
-
470
- {#if robot.hasConsumer}
471
- <p class="text-xs text-slate-600 dark:text-slate-500">
472
- Disconnect current input to join a room
473
- </p>
474
- {/if}
475
- {/if}
476
- </Card.Content>
477
- </Card.Root>
478
-
479
- <!-- Help Information -->
480
- <Alert.Root class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30">
481
- <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
482
- <Alert.Title class="text-slate-700 dark:text-slate-300">Input Sources</Alert.Title>
483
- <Alert.Description class="text-slate-600 text-xs dark:text-slate-400">
484
- <strong>USB:</strong> Read physical movements • <strong>Remote:</strong> Receive network commands • Only one active at a time
485
- </Alert.Description>
486
- </Alert.Root>
487
- {/if}
488
  </div>
489
  </div>
490
  </Dialog.Content>
491
- </Dialog.Root>
 
4
  import * as Card from "@/components/ui/card";
5
  import * as Alert from "@/components/ui/alert";
6
  import { Badge } from "@/components/ui/badge";
 
7
  import { toast } from "svelte-sonner";
8
 
9
  import type { Robot } from "$lib/elements/robot/Robot.svelte.js";
10
  import { robotManager } from "$lib/elements/robot/RobotManager.svelte.js";
 
11
  import USBCalibrationPanel from "$lib/elements/robot/calibration/USBCalibrationPanel.svelte";
12
+
13
  interface Props {
14
  workspaceId: string;
15
  open: boolean;
 
21
  let isConnecting = $state(false);
22
  let error = $state<string | null>(null);
23
  let showUSBCalibration = $state(false);
24
+ let pendingUSBConnection: "input" | null = $state(null);
25
+ let selectedRoomId = $state("");
26
+ let customRoomId = $state("");
27
  let showRoomManagement = $state(true);
28
  let hasLoadedRooms = $state(false);
29
 
 
33
  refreshRooms();
34
  hasLoadedRooms = true;
35
  }
36
+
37
  // Reset when modal closes
38
  if (!open) {
39
  hasLoadedRooms = false;
 
53
  error = null;
54
  await robotManager.refreshRooms(workspaceId);
55
  } catch (err) {
56
+ error = err instanceof Error ? err.message : "Failed to refresh rooms";
57
  }
58
  }
59
 
 
63
  error = null;
64
  const roomId = customRoomId.trim() || robot.id;
65
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
66
+
67
  if (result.success) {
68
+ customRoomId = "";
69
  await refreshRooms();
70
  toast.success("Room Created", {
71
  description: `Successfully created room ${result.roomId}`
72
  });
73
  } else {
74
+ error = result.error || "Failed to create room";
75
  }
76
  } catch (err) {
77
+ error = err instanceof Error ? err.message : "Failed to create room";
78
  } finally {
79
  isConnecting = false;
80
  }
 
82
 
83
  async function joinRoomAsInput() {
84
  if (!selectedRoomId) {
85
+ error = "Please select a room";
86
  return;
87
  }
88
+
89
  try {
90
  isConnecting = true;
91
  error = null;
 
94
  description: `Successfully joined room ${selectedRoomId} - now receiving commands`
95
  });
96
  } catch (err) {
97
+ error = err instanceof Error ? err.message : "Failed to join room as input";
98
  } finally {
99
  isConnecting = false;
100
  }
 
106
  error = null;
107
  const roomId = customRoomId.trim() || robot.id;
108
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
109
+
110
  if (result.success) {
111
  await robotManager.connectConsumerToRoom(workspaceId, robot.id, result.roomId!);
112
+ customRoomId = "";
113
  await refreshRooms();
114
  toast.success("Room Created & Joined", {
115
  description: `Successfully created and joined room ${result.roomId} - ready to receive commands`
116
  });
117
  } else {
118
+ error = result.error || "Failed to create room and join as input";
119
  }
120
  } catch (err) {
121
+ error = err instanceof Error ? err.message : "Failed to create room and join as input";
122
  } finally {
123
  isConnecting = false;
124
  }
 
130
  error = null;
131
 
132
  if (robot.calibrationManager.needsCalibration) {
133
+ pendingUSBConnection = "input";
134
  showUSBCalibration = true;
135
  return;
136
  }
137
 
138
  await robot.setConsumer({
139
+ type: "usb",
140
  baudRate: 1000000
141
  });
142
+
143
  toast.success("USB Input Connected", {
144
  description: "Successfully connected to physical robot hardware"
145
  });
146
  } catch (err) {
147
+ error = err instanceof Error ? err.message : "Unknown error";
148
  toast.error("Failed to Connect USB Input", {
149
  description: `Could not connect to robot hardware: ${error}`
150
  });
 
158
  isConnecting = true;
159
  error = null;
160
  await robot.removeConsumer();
161
+
162
  toast.success("Input Disconnected", {
163
  description: "Successfully disconnected input source"
164
  });
165
  } catch (err) {
166
+ error = err instanceof Error ? err.message : "Unknown error";
167
  toast.error("Failed to Disconnect Input", {
168
  description: `Could not disconnect input: ${error}`
169
  });
 
174
 
175
  async function onCalibrationComplete() {
176
  showUSBCalibration = false;
177
+
178
+ if (pendingUSBConnection === "input") {
179
  await connectUSBInput();
180
  }
181
+
182
  pendingUSBConnection = null;
183
  }
184
 
 
194
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
195
  >
196
  <Dialog.Header class="pb-3">
197
+ <Dialog.Title
198
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
199
+ >
200
+ <span class="icon-[mdi--account-supervisor] size-5 text-green-500 dark:text-green-400"
201
+ ></span>
202
  Input Connection - Robot {robot.id}
203
  </Dialog.Title>
204
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
205
+ Configure how this robot receives commands. Choose between direct hardware control or remote
206
+ collaboration.
207
  </Dialog.Description>
208
  </Dialog.Header>
209
 
 
211
  <div class="space-y-4 pb-4">
212
  <!-- Error display -->
213
  {#if error}
214
+ <Alert.Root
215
+ class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20"
216
+ >
217
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
218
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
219
+ <Alert.Description class="text-sm text-red-600 dark:text-red-400">
220
  {error}
221
  </Alert.Description>
222
  </Alert.Root>
223
  {/if}
224
 
225
+ <!-- USB Calibration Panel -->
226
+ {#if showUSBCalibration}
227
+ <Card.Root
228
+ class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20"
229
+ >
230
+ <Card.Header>
231
+ <div class="flex items-center justify-between">
232
+ <Card.Title class="text-lg font-semibold text-orange-700 dark:text-orange-200">
233
+ Hardware Calibration Required
234
+ </Card.Title>
235
+ <button
236
+ onclick={onCalibrationCancel}
237
+ class="text-slate-600 hover:text-slate-900 dark:text-gray-400 dark:hover:text-white"
238
+ >
239
+
240
+ </button>
241
+ </div>
242
+ </Card.Header>
243
+ <Card.Content class="space-y-4">
244
+ <Alert.Root
245
+ class="border-orange-300/30 bg-orange-100/10 dark:border-orange-500/30 dark:bg-orange-500/10"
246
  >
247
+ <span class="icon-[mdi--information] size-4 text-orange-500 dark:text-orange-400"
248
+ ></span>
249
+ <Alert.Description class="text-sm text-orange-700 dark:text-orange-200">
250
+ Before connecting to the physical robot, calibration is required to map the servo
251
+ positions to software values. This ensures accurate control.
252
+ </Alert.Description>
253
+ </Alert.Root>
254
+
255
+ <USBCalibrationPanel
256
+ calibrationManager={robot.calibrationManager}
257
+ connectionType="consumer"
258
+ {onCalibrationComplete}
259
+ onCancel={onCalibrationCancel}
260
+ />
261
+ </Card.Content>
262
+ </Card.Root>
263
+ {:else}
264
+ <!-- Current Status Overview -->
265
+ <Card.Root
266
+ class="border-green-300/30 bg-green-100/20 dark:border-green-500/30 dark:bg-green-900/20"
267
+ >
268
+ <Card.Content class="p-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  <div class="flex items-center justify-between">
270
+ <div class="flex items-center gap-2">
271
+ <span class="icon-[mdi--connection] size-4 text-green-500 dark:text-green-400"
272
+ ></span>
273
+ <span class="text-sm font-medium text-green-700 dark:text-green-300"
274
+ >Current Input Source</span
275
+ >
276
  </div>
277
+ {#if robot.hasConsumer}
278
+ <Badge variant="default" class="bg-green-500 text-xs dark:bg-green-600">
279
+ {robot.consumer?.name || "Connected"}
280
+ </Badge>
281
+ {:else}
282
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
283
+ >No Input Connected</Badge
284
+ >
285
+ {/if}
 
286
  </div>
287
+ {#if robot.hasConsumer}
288
+ <div class="mt-2 text-xs text-green-600/70 dark:text-green-400/70">
289
+ Status: {robot.consumer?.status.isConnected ? "Connected" : "Disconnected"}
290
+ </div>
291
+ {/if}
292
+ </Card.Content>
293
+ </Card.Root>
294
+
295
+ <!-- Local Hardware Connection -->
296
+ <Card.Root
297
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
298
+ >
299
+ <Card.Header>
300
+ <Card.Title
301
+ class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200"
302
+ >
303
+ <span class="icon-[mdi--usb-port] size-4"></span>
304
+ Local Hardware (USB)
 
 
 
 
 
 
 
 
 
 
 
 
305
  </Card.Title>
306
+ <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
307
+ Read physical robot movements in real-time
308
  </Card.Description>
309
+ </Card.Header>
310
+ <Card.Content class="space-y-3">
311
+ {#if robot.hasConsumer && robot.consumer?.name === "USB Consumer"}
312
+ <!-- USB Connected State -->
313
+ <div
314
+ class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20"
315
+ >
316
+ <div class="flex items-center justify-between">
317
+ <div>
318
+ <p class="text-sm font-medium text-blue-700 dark:text-blue-300">
319
+ Hardware Connected
320
+ </p>
321
+ <p class="text-xs text-blue-600/70 dark:text-blue-400/70">
322
+ Reading physical servo positions
323
+ </p>
324
+ </div>
325
+ <Button
326
+ variant="destructive"
327
+ size="sm"
328
+ onclick={disconnectInput}
329
+ disabled={isConnecting}
330
+ class="h-7 px-2 text-xs"
331
+ >
332
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
333
+ {isConnecting ? "Disconnecting..." : "Disconnect"}
334
+ </Button>
335
+ </div>
336
+ </div>
337
  {:else}
338
+ <!-- USB Connection Button -->
339
+ <Button
340
+ variant="secondary"
341
+ onclick={connectUSBInput}
342
+ disabled={isConnecting || robot.hasConsumer}
343
+ class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
344
+ >
345
+ <span class="icon-[mdi--usb] mr-2 size-4"></span>
346
+ {isConnecting ? "Connecting..." : "Connect to Hardware"}
347
+ </Button>
348
+
349
+ {#if robot.hasConsumer}
350
+ <p class="text-xs text-slate-600 dark:text-slate-500">
351
+ Disconnect current input to connect USB hardware
352
+ </p>
353
+ {/if}
354
  {/if}
355
+ </Card.Content>
356
+ </Card.Root>
357
+
358
+ <!-- Remote Collaboration -->
359
+ <Card.Root
360
+ class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5"
361
+ >
362
+ <Card.Header>
363
  <div class="flex items-center justify-between">
364
  <div>
365
+ <Card.Title
366
+ class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200"
367
+ >
368
+ <span class="icon-[mdi--cloud-sync] size-4"></span>
369
+ Remote Collaboration (Rooms)
370
+ </Card.Title>
371
+ <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
372
+ Receive commands from AI systems, remote users, or other software
373
+ </Card.Description>
374
  </div>
375
  <Button
376
+ variant="ghost"
377
  size="sm"
378
+ onclick={refreshRooms}
379
+ disabled={robotManager.roomsLoading || isConnecting}
380
+ class="h-7 px-2 text-xs text-purple-700 hover:bg-purple-200/20 hover:text-purple-800 dark:text-purple-300 dark:hover:bg-purple-500/20 dark:hover:text-purple-200"
381
  >
382
+ {#if robotManager.roomsLoading}
383
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
384
+ Refreshing
385
+ {:else}
386
+ <span class="icon-[mdi--refresh] mr-1 size-3"></span>
387
+ Refresh
388
+ {/if}
389
  </Button>
390
  </div>
391
+ </Card.Header>
392
+ <Card.Content class="space-y-4">
393
+ {#if robot.hasConsumer && robot.consumer?.name?.includes("Remote Consumer")}
394
+ <!-- Remote Connected State -->
395
+ <div
396
+ class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20"
397
+ >
398
+ <div class="flex items-center justify-between">
399
+ <div>
400
+ <p class="text-sm font-medium text-purple-700 dark:text-purple-300">
401
+ Room Connected
402
+ </p>
403
+ <p class="text-xs text-purple-600/70 dark:text-purple-400/70">
404
+ Receiving remote commands
405
+ </p>
406
+ </div>
407
+ <Button
408
+ variant="destructive"
409
+ size="sm"
410
+ onclick={disconnectInput}
411
+ disabled={isConnecting}
412
+ class="h-7 px-2 text-xs"
413
+ >
414
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
415
+ {isConnecting ? "Leaving..." : "Leave Room"}
416
+ </Button>
417
+ </div>
418
  </div>
419
+ {:else}
420
+ <!-- Create New Room -->
421
+ <div
422
+ class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5"
423
+ >
424
+ <div class="space-y-2">
425
+ <div class="flex items-center gap-2">
426
+ <span
427
+ class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"
428
+ ></span>
429
+ <p class="text-sm font-medium text-green-700 dark:text-green-300">
430
+ Create New Room
431
+ </p>
432
+ </div>
433
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
434
+ Create a room where others can send commands to this robot
435
+ </p>
436
+ <input
437
+ bind:value={customRoomId}
438
+ placeholder={`Room ID (default: ${robot.id})`}
439
+ disabled={isConnecting || robot.hasConsumer}
440
+ class="w-full rounded border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-900 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
441
+ />
442
+ <div class="flex gap-1">
443
+ <Button
444
+ variant="secondary"
445
+ size="sm"
446
+ onclick={createRoom}
447
+ disabled={isConnecting || robot.hasConsumer}
448
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
449
+ >
450
+ Create Only
451
+ </Button>
452
+ <Button
453
+ variant="secondary"
454
+ size="sm"
455
+ onclick={createRoomAndJoinAsInput}
456
+ disabled={isConnecting || robot.hasConsumer}
457
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
458
+ >
459
+ Create & Join as Input
460
+ </Button>
461
+ </div>
462
+ </div>
463
  </div>
 
 
464
 
465
+ <!-- Existing Rooms -->
466
+ <div class="space-y-2">
467
+ <div class="flex items-center justify-between">
468
+ <span class="text-xs font-medium text-purple-700 dark:text-purple-300"
469
+ >Join Existing Room:</span
470
+ >
471
+ <span class="text-xs text-slate-600 dark:text-slate-400">
472
+ {robotManager.rooms.length} room{robotManager.rooms.length !== 1 ? "s" : ""} available
473
+ </span>
 
 
 
 
474
  </div>
475
+
476
+ <div class="max-h-40 space-y-2 overflow-y-auto">
477
+ {#if robotManager.rooms.length === 0}
478
+ <div class="py-3 text-center text-xs text-slate-600 dark:text-slate-400">
479
+ {robotManager.roomsLoading
480
+ ? "Loading rooms..."
481
+ : "No rooms available. Create one to get started."}
482
+ </div>
483
+ {:else}
484
+ {#each robotManager.rooms as room}
485
+ <div
486
+ class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50"
487
+ >
488
+ <div class="flex items-start justify-between gap-3">
489
+ <div class="min-w-0 flex-1">
490
+ <p
491
+ class="truncate text-xs font-medium text-slate-800 dark:text-slate-200"
492
+ >
493
+ {room.id}
494
+ </p>
495
+ <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
496
+ <span>{room.has_producer ? "📤 Has Output" : "📥 No Output"}</span>
497
+ <span>👥 {room.participants?.total || 0} users</span>
498
+ </div>
499
+ </div>
500
+ <Button
501
+ variant="secondary"
502
+ size="sm"
503
+ onclick={() => {
504
+ selectedRoomId = room.id;
505
+ joinRoomAsInput();
506
+ }}
507
+ disabled={isConnecting || robot.hasConsumer}
508
+ class="h-6 shrink-0 bg-purple-500 px-2 text-xs hover:bg-purple-600 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
509
+ >
510
+ <span class="icon-[mdi--login] mr-1 size-3"></span>
511
+ Join as Input
512
+ </Button>
513
  </div>
514
  </div>
515
+ {/each}
516
+ {/if}
517
+ </div>
518
+ </div>
519
+
520
+ {#if robot.hasConsumer}
521
+ <p class="text-xs text-slate-600 dark:text-slate-500">
522
+ Disconnect current input to join a room
523
+ </p>
 
 
 
 
 
 
 
524
  {/if}
525
+ {/if}
526
+ </Card.Content>
527
+ </Card.Root>
528
+
529
+ <!-- Help Information -->
530
+ <Alert.Root
531
+ class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30"
532
+ >
533
+ <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
534
+ <Alert.Title class="text-slate-700 dark:text-slate-300">Input Sources</Alert.Title>
535
+ <Alert.Description class="text-xs text-slate-600 dark:text-slate-400">
536
+ <strong>USB:</strong> Read physical movements • <strong>Remote:</strong> Receive network
537
+ commands Only one active at a time
538
+ </Alert.Description>
539
+ </Alert.Root>
540
+ {/if}
 
 
 
 
 
541
  </div>
542
  </div>
543
  </Dialog.Content>
544
+ </Dialog.Root>
src/lib/components/3d/elements/robot/modal/ManualControlSheet.svelte CHANGED
@@ -19,16 +19,22 @@
19
  <Sheet.Content
20
  trapFocus={false}
21
  side="right"
22
- class="w-80 gap-0 border-l border-slate-300 bg-gradient-to-b from-slate-100 to-slate-200 p-0 text-slate-900 dark:border-slate-600 dark:bg-gradient-to-b dark:from-slate-700 dark:to-slate-800 dark:text-white sm:w-96"
23
  >
24
  <!-- Header -->
25
- <Sheet.Header class="border-b border-slate-300 bg-slate-200/80 p-6 backdrop-blur-sm dark:border-slate-600 dark:bg-slate-700/80">
 
 
26
  <div class="flex items-center justify-between">
27
  <div class="flex items-center gap-3">
28
  <span class="icon-[mdi--tune] size-6 text-purple-500 dark:text-purple-400"></span>
29
  <div>
30
- <Sheet.Title class="text-xl font-semibold text-slate-900 dark:text-slate-100">Manual Control</Sheet.Title>
31
- <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">Direct robot joint manipulation</p>
 
 
 
 
32
  </div>
33
  </div>
34
  </div>
@@ -36,41 +42,54 @@
36
 
37
  {#if robot}
38
  <!-- Content -->
39
- <div class="scrollbar-thin scrollbar-track-slate-300 scrollbar-thumb-slate-500 flex-1 overflow-y-auto px-4 dark:scrollbar-track-slate-700 dark:scrollbar-thumb-slate-500">
 
 
40
  <div class="space-y-6 py-4">
41
  <!-- Manual Joint Controls -->
42
  {#if robot.isManualControlEnabled}
43
  <div class="space-y-4">
44
  <div class="mb-3 flex items-center gap-3">
45
- <span class="icon-[lucide--rotate-3d] size-5 text-purple-500 dark:text-purple-400"></span>
46
- <h3 class="text-lg font-medium text-slate-900 dark:text-slate-100">Joint Controls</h3>
 
 
 
47
  <Badge variant="default" class="ml-auto bg-purple-500 text-xs dark:bg-purple-600">
48
  {robot.jointArray.length}
49
  </Badge>
50
  </div>
51
 
52
  <p class="text-xs text-slate-600 dark:text-slate-400">
53
- Each joint can be moved independently using sliders. Values are normalized percentages.
 
54
  </p>
55
 
56
  {#if robot.jointArray.length === 0}
57
- <p class="py-4 text-center text-xs text-slate-600 italic dark:text-slate-500">No joints available</p>
 
 
58
  {:else}
59
  <div class="space-y-3">
60
  {#each robot.jointArray as joint (joint.name)}
61
- {@const isGripper = joint.name.toLowerCase() === 'jaw' || joint.name.toLowerCase() === 'gripper'}
 
62
  {@const minValue = isGripper ? 0 : -100}
63
  {@const maxValue = isGripper ? 100 : 100}
64
-
65
- <div class="space-y-2 rounded-lg border border-slate-300 bg-slate-100/50 p-3 dark:border-slate-600 dark:bg-slate-800/50">
 
 
66
  <div class="flex items-center justify-between">
67
- <span class="text-sm font-medium text-slate-800 dark:text-slate-200">{joint.name}</span>
 
 
68
  <div class="flex items-center gap-2 text-xs">
69
  <span class="font-mono text-purple-600 dark:text-purple-400">
70
- {joint.value.toFixed(1)}{isGripper ? '%' : '%'}
71
  </span>
72
  {#if joint.limits}
73
- <span class="font-mono text-slate-500 text-[10px] dark:text-slate-500">
74
  ({joint.limits.lower.toFixed(1)}° to {joint.limits.upper.toFixed(1)}°)
75
  </span>
76
  {/if}
@@ -89,9 +108,11 @@
89
  }}
90
  class="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-300 dark:bg-slate-600"
91
  />
92
- <div class="flex justify-between text-xs text-slate-600 dark:text-slate-500">
93
- <span>{minValue}{isGripper ? '% (closed)' : '%'}</span>
94
- <span>{maxValue}{isGripper ? '% (open)' : '%'}</span>
 
 
95
  </div>
96
  </div>
97
  </div>
@@ -101,17 +122,24 @@
101
  </div>
102
  {:else}
103
  <div class="space-y-4">
104
- <div class="mb-3 flex items-center gap-3">
105
- <h3 class="text-lg font-medium text-slate-900 dark:text-slate-100">Input Control Active</h3>
106
- </div>
 
 
107
 
108
- <Alert.Root class="border-purple-300/30 bg-purple-100/10 dark:border-purple-500/30 dark:bg-purple-500/10">
109
- <Alert.Title class="text-sm text-purple-700 dark:text-purple-200">Input Control Active</Alert.Title>
110
- <Alert.Description class="text-xs text-purple-700 dark:text-purple-300">
111
- Robot controlled by: <strong>{robot.consumer?.name || 'External Input'}</strong><br />
112
- Disconnect input to enable manual control.
113
- </Alert.Description>
114
- </Alert.Root>
 
 
 
 
 
115
  </div>
116
  {/if}
117
  </div>
 
19
  <Sheet.Content
20
  trapFocus={false}
21
  side="right"
22
+ class="w-80 gap-0 border-l border-slate-300 bg-gradient-to-b from-slate-100 to-slate-200 p-0 text-slate-900 sm:w-96 dark:border-slate-600 dark:bg-gradient-to-b dark:from-slate-700 dark:to-slate-800 dark:text-white"
23
  >
24
  <!-- Header -->
25
+ <Sheet.Header
26
+ class="border-b border-slate-300 bg-slate-200/80 p-6 backdrop-blur-sm dark:border-slate-600 dark:bg-slate-700/80"
27
+ >
28
  <div class="flex items-center justify-between">
29
  <div class="flex items-center gap-3">
30
  <span class="icon-[mdi--tune] size-6 text-purple-500 dark:text-purple-400"></span>
31
  <div>
32
+ <Sheet.Title class="text-xl font-semibold text-slate-900 dark:text-slate-100"
33
+ >Manual Control</Sheet.Title
34
+ >
35
+ <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
36
+ Direct robot joint manipulation
37
+ </p>
38
  </div>
39
  </div>
40
  </div>
 
42
 
43
  {#if robot}
44
  <!-- Content -->
45
+ <div
46
+ class="scrollbar-thin scrollbar-track-slate-300 scrollbar-thumb-slate-500 dark:scrollbar-track-slate-700 dark:scrollbar-thumb-slate-500 flex-1 overflow-y-auto px-4"
47
+ >
48
  <div class="space-y-6 py-4">
49
  <!-- Manual Joint Controls -->
50
  {#if robot.isManualControlEnabled}
51
  <div class="space-y-4">
52
  <div class="mb-3 flex items-center gap-3">
53
+ <span class="icon-[lucide--rotate-3d] size-5 text-purple-500 dark:text-purple-400"
54
+ ></span>
55
+ <h3 class="text-lg font-medium text-slate-900 dark:text-slate-100">
56
+ Joint Controls
57
+ </h3>
58
  <Badge variant="default" class="ml-auto bg-purple-500 text-xs dark:bg-purple-600">
59
  {robot.jointArray.length}
60
  </Badge>
61
  </div>
62
 
63
  <p class="text-xs text-slate-600 dark:text-slate-400">
64
+ Each joint can be moved independently using sliders. Values are normalized
65
+ percentages.
66
  </p>
67
 
68
  {#if robot.jointArray.length === 0}
69
+ <p class="py-4 text-center text-xs text-slate-600 italic dark:text-slate-500">
70
+ No joints available
71
+ </p>
72
  {:else}
73
  <div class="space-y-3">
74
  {#each robot.jointArray as joint (joint.name)}
75
+ {@const isGripper =
76
+ joint.name.toLowerCase() === "jaw" || joint.name.toLowerCase() === "gripper"}
77
  {@const minValue = isGripper ? 0 : -100}
78
  {@const maxValue = isGripper ? 100 : 100}
79
+
80
+ <div
81
+ class="space-y-2 rounded-lg border border-slate-300 bg-slate-100/50 p-3 dark:border-slate-600 dark:bg-slate-800/50"
82
+ >
83
  <div class="flex items-center justify-between">
84
+ <span class="text-sm font-medium text-slate-800 dark:text-slate-200"
85
+ >{joint.name}</span
86
+ >
87
  <div class="flex items-center gap-2 text-xs">
88
  <span class="font-mono text-purple-600 dark:text-purple-400">
89
+ {joint.value.toFixed(1)}{isGripper ? "%" : "%"}
90
  </span>
91
  {#if joint.limits}
92
+ <span class="font-mono text-[10px] text-slate-500 dark:text-slate-500">
93
  ({joint.limits.lower.toFixed(1)}° to {joint.limits.upper.toFixed(1)}°)
94
  </span>
95
  {/if}
 
108
  }}
109
  class="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-slate-300 dark:bg-slate-600"
110
  />
111
+ <div
112
+ class="flex justify-between text-xs text-slate-600 dark:text-slate-500"
113
+ >
114
+ <span>{minValue}{isGripper ? "% (closed)" : "%"}</span>
115
+ <span>{maxValue}{isGripper ? "% (open)" : "%"}</span>
116
  </div>
117
  </div>
118
  </div>
 
122
  </div>
123
  {:else}
124
  <div class="space-y-4">
125
+ <div class="mb-3 flex items-center gap-3">
126
+ <h3 class="text-lg font-medium text-slate-900 dark:text-slate-100">
127
+ Input Control Active
128
+ </h3>
129
+ </div>
130
 
131
+ <Alert.Root
132
+ class="border-purple-300/30 bg-purple-100/10 dark:border-purple-500/30 dark:bg-purple-500/10"
133
+ >
134
+ <Alert.Title class="text-sm text-purple-700 dark:text-purple-200"
135
+ >Input Control Active</Alert.Title
136
+ >
137
+ <Alert.Description class="text-xs text-purple-700 dark:text-purple-300">
138
+ Robot controlled by: <strong>{robot.consumer?.name || "External Input"}</strong
139
+ ><br />
140
+ Disconnect input to enable manual control.
141
+ </Alert.Description>
142
+ </Alert.Root>
143
  </div>
144
  {/if}
145
  </div>
src/lib/components/3d/elements/robot/modal/OutputConnectionModal.svelte CHANGED
@@ -25,11 +25,11 @@
25
 
26
  // USB connection flow state
27
  let showUSBCalibration = $state(false);
28
- let pendingUSBConnection: 'output' | null = $state(null);
29
 
30
  // Room management state
31
- let selectedRoomId = $state('');
32
- let customRoomId = $state('');
33
  let hasLoadedRooms = $state(false);
34
 
35
  // Reactive state from robot
@@ -42,7 +42,7 @@
42
  refreshRooms();
43
  hasLoadedRooms = true;
44
  }
45
-
46
  // Reset when modal closes
47
  if (!open) {
48
  hasLoadedRooms = false;
@@ -63,7 +63,7 @@
63
  error = null;
64
  await robotManager.refreshRooms(workspaceId);
65
  } catch (err) {
66
- error = err instanceof Error ? err.message : 'Failed to refresh rooms';
67
  }
68
  }
69
 
@@ -73,18 +73,18 @@
73
  error = null;
74
  const roomId = customRoomId.trim() || robot.id;
75
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
76
-
77
  if (result.success) {
78
- customRoomId = '';
79
  await refreshRooms();
80
  toast.success("Room Created", {
81
  description: `Successfully created room ${result.roomId}`
82
  });
83
  } else {
84
- error = result.error || 'Failed to create room';
85
  }
86
  } catch (err) {
87
- error = err instanceof Error ? err.message : 'Failed to create room';
88
  } finally {
89
  isConnecting = false;
90
  }
@@ -92,10 +92,10 @@
92
 
93
  async function joinRoomAsOutput() {
94
  if (!selectedRoomId) {
95
- error = 'Please select a room';
96
  return;
97
  }
98
-
99
  try {
100
  isConnecting = true;
101
  error = null;
@@ -104,7 +104,7 @@
104
  description: `Successfully joined room ${selectedRoomId} - now sending commands`
105
  });
106
  } catch (err) {
107
- error = err instanceof Error ? err.message : 'Failed to join room as output';
108
  } finally {
109
  isConnecting = false;
110
  }
@@ -116,18 +116,18 @@
116
  error = null;
117
  const roomId = customRoomId.trim() || robot.id;
118
  const result = await robotManager.connectProducerAsProducer(workspaceId, robot.id, roomId);
119
-
120
  if (result.success) {
121
- customRoomId = '';
122
  await refreshRooms();
123
  toast.success("Room Created & Joined", {
124
  description: `Successfully created and joined room ${result.roomId} - ready to send commands`
125
  });
126
  } else {
127
- error = result.error || 'Failed to create room and join as output';
128
  }
129
  } catch (err) {
130
- error = err instanceof Error ? err.message : 'Failed to create room and join as output';
131
  } finally {
132
  isConnecting = false;
133
  }
@@ -140,21 +140,21 @@
140
 
141
  // Check if calibration is needed
142
  if (robot.calibrationManager.needsCalibration) {
143
- pendingUSBConnection = 'output';
144
  showUSBCalibration = true;
145
  return;
146
  }
147
 
148
  await robot.addProducer({
149
- type: 'usb',
150
  baudRate: 1000000
151
  });
152
-
153
  toast.success("USB Output Connected", {
154
  description: "Successfully connected to physical robot hardware"
155
  });
156
  } catch (err) {
157
- error = err instanceof Error ? err.message : 'Unknown error';
158
  toast.error("Failed to Connect USB Output", {
159
  description: `Could not connect to robot hardware: ${error}`
160
  });
@@ -168,12 +168,12 @@
168
  isConnecting = true;
169
  error = null;
170
  await robot.removeProducer(producerId);
171
-
172
  toast.success("Output Disconnected", {
173
  description: "Successfully disconnected output"
174
  });
175
  } catch (err) {
176
- error = err instanceof Error ? err.message : 'Unknown error';
177
  toast.error("Failed to Disconnect Output", {
178
  description: `Could not disconnect output: ${error}`
179
  });
@@ -185,11 +185,11 @@
185
  // Handle calibration completion
186
  async function onCalibrationComplete() {
187
  showUSBCalibration = false;
188
-
189
- if (pendingUSBConnection === 'output') {
190
  await connectUSBOutput();
191
  }
192
-
193
  pendingUSBConnection = null;
194
  }
195
 
@@ -205,12 +205,15 @@
205
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
206
  >
207
  <Dialog.Header class="pb-3">
208
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
 
 
209
  <span class="icon-[mdi--devices] size-5 text-blue-500 dark:text-blue-400"></span>
210
  Output Connection - Robot {robot.id}
211
  </Dialog.Title>
212
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
213
- Configure where this robot sends its movements. Multiple outputs can be active simultaneously.
 
214
  </Dialog.Description>
215
  </Dialog.Header>
216
 
@@ -218,10 +221,12 @@
218
  <div class="space-y-4 pb-4">
219
  <!-- Error display -->
220
  {#if error}
221
- <Alert.Root class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20">
 
 
222
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
223
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
224
- <Alert.Description class="text-red-600 text-sm dark:text-red-400">
225
  {error}
226
  </Alert.Description>
227
  </Alert.Root>
@@ -229,9 +234,11 @@
229
 
230
  <!-- USB Calibration Panel -->
231
  {#if showUSBCalibration}
232
- <Card.Root class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20">
 
 
233
  <Card.Header>
234
- <div class="flex justify-between items-center">
235
  <Card.Title class="text-lg font-semibold text-orange-700 dark:text-orange-200">
236
  Hardware Calibration Required
237
  </Card.Title>
@@ -244,14 +251,18 @@
244
  </div>
245
  </Card.Header>
246
  <Card.Content class="space-y-4">
247
- <Alert.Root class="border-orange-300/30 bg-orange-100/10 dark:border-orange-500/30 dark:bg-orange-500/10">
248
- <span class="icon-[mdi--information] size-4 text-orange-500 dark:text-orange-400"></span>
249
- <Alert.Description class="text-orange-700 text-sm dark:text-orange-200">
250
- Before connecting to the physical robot, calibration is required to map the servo positions to software values. This ensures accurate control.
 
 
 
 
251
  </Alert.Description>
252
  </Alert.Root>
253
 
254
- <USBCalibrationPanel
255
  calibrationManager={robot.calibrationManager}
256
  connectionType="producer"
257
  {onCalibrationComplete}
@@ -260,222 +271,261 @@
260
  </Card.Content>
261
  </Card.Root>
262
  {:else}
263
-
264
- <!-- Current Status Overview -->
265
- <Card.Root class="border-blue-300/30 bg-blue-100/20 dark:border-blue-500/30 dark:bg-blue-900/20">
266
- <Card.Content class="p-4">
267
- <div class="flex items-center justify-between">
268
- <div class="flex items-center gap-2">
269
- <span class="icon-[mdi--broadcast] size-4 text-blue-500 dark:text-blue-400"></span>
270
- <span class="text-sm font-medium text-blue-700 dark:text-blue-300">Active Outputs</span>
271
- </div>
272
- <Badge variant="default" class="bg-blue-500 text-xs dark:bg-blue-600">
273
- {outputDriverCount} Connected
274
- </Badge>
275
- </div>
276
- </Card.Content>
277
- </Card.Root>
278
-
279
- <!-- Local Hardware Connection -->
280
- <Card.Root class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5">
281
- <Card.Header>
282
- <Card.Title class="flex items-center gap-2 text-base text-green-700 dark:text-green-200">
283
- <span class="icon-[mdi--usb-port] size-4"></span>
284
- Local Hardware (USB)
285
- </Card.Title>
286
- <Card.Description class="text-xs text-green-600/70 dark:text-green-300/70">
287
- Send commands directly to physical robot hardware
288
- </Card.Description>
289
- </Card.Header>
290
- <Card.Content class="space-y-3">
291
- <Button
292
- variant="secondary"
293
- onclick={connectUSBOutput}
294
- disabled={isConnecting}
295
- class="w-full bg-green-500 text-sm text-white hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
296
- >
297
- <span class="icon-[mdi--usb] mr-2 size-4"></span>
298
- {isConnecting ? 'Connecting...' : 'Add USB Output'}
299
- </Button>
300
- </Card.Content>
301
- </Card.Root>
302
-
303
- <!-- Remote Collaboration -->
304
- <Card.Root class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5">
305
- <Card.Header>
306
- <div class="flex items-center justify-between">
307
- <div>
308
- <Card.Title class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200">
309
- <span class="icon-[mdi--cloud-sync] size-4"></span>
310
- Remote Collaboration (Rooms)
311
- </Card.Title>
312
- <Card.Description class="text-xs text-orange-600/70 dark:text-orange-300/70">
313
- Broadcast robot movements to remote systems and AI
314
- </Card.Description>
315
- </div>
316
- <Button
317
- variant="ghost"
318
- size="sm"
319
- onclick={refreshRooms}
320
- disabled={robotManager.roomsLoading || isConnecting}
321
- class="h-7 px-2 text-xs text-orange-700 hover:text-orange-800 hover:bg-orange-200/20 dark:text-orange-300 dark:hover:text-orange-200 dark:hover:bg-orange-500/20"
322
- >
323
- {#if robotManager.roomsLoading}
324
- <span class="icon-[mdi--loading] animate-spin size-3 mr-1"></span>
325
- Refreshing
326
- {:else}
327
- <span class="icon-[mdi--refresh] size-3 mr-1"></span>
328
- Refresh
329
- {/if}
330
- </Button>
331
- </div>
332
- </Card.Header>
333
- <Card.Content class="space-y-4">
334
- <!-- Create New Room -->
335
- <div class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5">
336
- <div class="space-y-2">
337
  <div class="flex items-center gap-2">
338
- <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"></span>
339
- <p class="text-sm font-medium text-green-700 dark:text-green-300">Create New Room</p>
340
- </div>
341
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
342
- Create a room to broadcast this robot's movements
343
- </p>
344
- <input
345
- bind:value={customRoomId}
346
- placeholder={`Room ID (default: ${robot.id})`}
347
- disabled={isConnecting}
348
- class="w-full px-2 py-1 bg-slate-50 border border-slate-300 rounded text-xs text-slate-900 disabled:opacity-50 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
349
- />
350
- <div class="flex gap-1">
351
- <Button
352
- variant="secondary"
353
- size="sm"
354
- onclick={createRoom}
355
- disabled={isConnecting}
356
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
357
- >
358
- Create Only
359
- </Button>
360
- <Button
361
- variant="secondary"
362
- size="sm"
363
- onclick={createRoomAndJoinAsOutput}
364
- disabled={isConnecting}
365
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
366
  >
367
- Create & Join as Output
368
- </Button>
369
  </div>
 
 
 
370
  </div>
371
- </div>
372
-
373
- <!-- Existing Rooms -->
374
- <div class="space-y-2">
375
- <div class="flex items-center justify-between">
376
- <span class="text-xs font-medium text-orange-700 dark:text-orange-300">Join Existing Room:</span>
377
- <span class="text-xs text-slate-600 dark:text-slate-400">
378
- {robotManager.rooms.length} room{robotManager.rooms.length !== 1 ? 's' : ''} available
379
- </span>
380
- </div>
381
-
382
- <div class="max-h-40 space-y-2 overflow-y-auto">
383
- {#if robotManager.rooms.length === 0}
384
- <div class="text-center py-3 text-xs text-slate-600 dark:text-slate-400">
385
- {robotManager.roomsLoading ? 'Loading rooms...' : 'No rooms available. Create one to get started.'}
386
- </div>
387
- {:else}
388
- {#each robotManager.rooms as room}
389
- <div class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50">
390
- <div class="flex items-start justify-between gap-3">
391
- <div class="flex-1 min-w-0">
392
- <p class="text-xs font-medium text-slate-800 truncate dark:text-slate-200">
393
- {room.id}
394
- </p>
395
- <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
396
- <span>{room.has_producer ? '🔴 Occupied' : '🟢 Available'}</span>
397
- <span>👥 {room.participants?.total || 0} users</span>
398
- </div>
399
- </div>
400
- {#if !room.has_producer}
401
- <Button
402
- variant="secondary"
403
- size="sm"
404
- onclick={() => {
405
- selectedRoomId = room.id;
406
- joinRoomAsOutput();
407
- }}
408
- disabled={isConnecting}
409
- class="h-6 px-2 text-xs bg-orange-500 hover:bg-orange-600 shrink-0 dark:bg-orange-600 dark:hover:bg-orange-700"
410
- >
411
- <span class="icon-[mdi--login] mr-1 size-3"></span>
412
- Join as Output
413
- </Button>
414
- {:else}
415
- <Button
416
- variant="ghost"
417
- size="sm"
418
- disabled
419
- class="h-6 px-2 text-xs opacity-50 shrink-0"
420
- >
421
- Occupied
422
- </Button>
423
- {/if}
424
- </div>
425
- </div>
426
- {/each}
427
- {/if}
428
- </div>
429
- </div>
430
- </Card.Content>
431
- </Card.Root>
432
 
433
- <!-- Connected Outputs -->
434
- {#if producers.length > 0}
435
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
 
436
  <Card.Header>
437
- <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
438
- <span class="icon-[mdi--connection] size-4"></span>
439
- Connected Outputs
 
 
440
  </Card.Title>
 
 
 
441
  </Card.Header>
442
- <Card.Content>
443
- <div class="max-h-32 space-y-2 overflow-y-auto">
444
- {#each producers as producer}
445
- <div class="flex items-center justify-between rounded-md bg-slate-100/50 p-2 dark:bg-slate-700/50">
446
- <div class="flex items-center gap-2">
447
- <span
448
- class="size-2 rounded-full {producer.status.isConnected ? 'bg-green-500 dark:bg-green-400' : 'bg-red-500 dark:bg-red-400'}"
449
- ></span>
450
- <span class="text-sm text-slate-700 dark:text-slate-300">{producer.name}</span>
451
- <Badge variant="secondary" class="text-xs">{producer.id.slice(0, 12)}</Badge>
452
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  <Button
454
- variant="destructive"
455
  size="sm"
456
- onclick={() => disconnectOutput(producer.id)}
457
  disabled={isConnecting}
458
- class="h-6 px-2 text-xs"
459
  >
460
- <span class="icon-[mdi--close] size-3"></span>
461
  </Button>
462
  </div>
463
- {/each}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  </div>
465
  </Card.Content>
466
  </Card.Root>
467
- {/if}
468
 
469
- <!-- Help Information -->
470
- <Alert.Root class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30">
471
- <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
472
- <Alert.Title class="text-slate-700 dark:text-slate-300">Output Sources</Alert.Title>
473
- <Alert.Description class="text-slate-600 text-xs dark:text-slate-400">
474
- <strong>USB:</strong> Control physical hardware • <strong>Remote:</strong> Broadcast to network • Multiple outputs can be active
475
- </Alert.Description>
476
- </Alert.Root>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  {/if}
478
  </div>
479
  </div>
480
  </Dialog.Content>
481
- </Dialog.Root>
 
25
 
26
  // USB connection flow state
27
  let showUSBCalibration = $state(false);
28
+ let pendingUSBConnection: "output" | null = $state(null);
29
 
30
  // Room management state
31
+ let selectedRoomId = $state("");
32
+ let customRoomId = $state("");
33
  let hasLoadedRooms = $state(false);
34
 
35
  // Reactive state from robot
 
42
  refreshRooms();
43
  hasLoadedRooms = true;
44
  }
45
+
46
  // Reset when modal closes
47
  if (!open) {
48
  hasLoadedRooms = false;
 
63
  error = null;
64
  await robotManager.refreshRooms(workspaceId);
65
  } catch (err) {
66
+ error = err instanceof Error ? err.message : "Failed to refresh rooms";
67
  }
68
  }
69
 
 
73
  error = null;
74
  const roomId = customRoomId.trim() || robot.id;
75
  const result = await robotManager.createRoboticsRoom(workspaceId, roomId);
76
+
77
  if (result.success) {
78
+ customRoomId = "";
79
  await refreshRooms();
80
  toast.success("Room Created", {
81
  description: `Successfully created room ${result.roomId}`
82
  });
83
  } else {
84
+ error = result.error || "Failed to create room";
85
  }
86
  } catch (err) {
87
+ error = err instanceof Error ? err.message : "Failed to create room";
88
  } finally {
89
  isConnecting = false;
90
  }
 
92
 
93
  async function joinRoomAsOutput() {
94
  if (!selectedRoomId) {
95
+ error = "Please select a room";
96
  return;
97
  }
98
+
99
  try {
100
  isConnecting = true;
101
  error = null;
 
104
  description: `Successfully joined room ${selectedRoomId} - now sending commands`
105
  });
106
  } catch (err) {
107
+ error = err instanceof Error ? err.message : "Failed to join room as output";
108
  } finally {
109
  isConnecting = false;
110
  }
 
116
  error = null;
117
  const roomId = customRoomId.trim() || robot.id;
118
  const result = await robotManager.connectProducerAsProducer(workspaceId, robot.id, roomId);
119
+
120
  if (result.success) {
121
+ customRoomId = "";
122
  await refreshRooms();
123
  toast.success("Room Created & Joined", {
124
  description: `Successfully created and joined room ${result.roomId} - ready to send commands`
125
  });
126
  } else {
127
+ error = result.error || "Failed to create room and join as output";
128
  }
129
  } catch (err) {
130
+ error = err instanceof Error ? err.message : "Failed to create room and join as output";
131
  } finally {
132
  isConnecting = false;
133
  }
 
140
 
141
  // Check if calibration is needed
142
  if (robot.calibrationManager.needsCalibration) {
143
+ pendingUSBConnection = "output";
144
  showUSBCalibration = true;
145
  return;
146
  }
147
 
148
  await robot.addProducer({
149
+ type: "usb",
150
  baudRate: 1000000
151
  });
152
+
153
  toast.success("USB Output Connected", {
154
  description: "Successfully connected to physical robot hardware"
155
  });
156
  } catch (err) {
157
+ error = err instanceof Error ? err.message : "Unknown error";
158
  toast.error("Failed to Connect USB Output", {
159
  description: `Could not connect to robot hardware: ${error}`
160
  });
 
168
  isConnecting = true;
169
  error = null;
170
  await robot.removeProducer(producerId);
171
+
172
  toast.success("Output Disconnected", {
173
  description: "Successfully disconnected output"
174
  });
175
  } catch (err) {
176
+ error = err instanceof Error ? err.message : "Unknown error";
177
  toast.error("Failed to Disconnect Output", {
178
  description: `Could not disconnect output: ${error}`
179
  });
 
185
  // Handle calibration completion
186
  async function onCalibrationComplete() {
187
  showUSBCalibration = false;
188
+
189
+ if (pendingUSBConnection === "output") {
190
  await connectUSBOutput();
191
  }
192
+
193
  pendingUSBConnection = null;
194
  }
195
 
 
205
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
206
  >
207
  <Dialog.Header class="pb-3">
208
+ <Dialog.Title
209
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
210
+ >
211
  <span class="icon-[mdi--devices] size-5 text-blue-500 dark:text-blue-400"></span>
212
  Output Connection - Robot {robot.id}
213
  </Dialog.Title>
214
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
215
+ Configure where this robot sends its movements. Multiple outputs can be active
216
+ simultaneously.
217
  </Dialog.Description>
218
  </Dialog.Header>
219
 
 
221
  <div class="space-y-4 pb-4">
222
  <!-- Error display -->
223
  {#if error}
224
+ <Alert.Root
225
+ class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20"
226
+ >
227
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
228
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
229
+ <Alert.Description class="text-sm text-red-600 dark:text-red-400">
230
  {error}
231
  </Alert.Description>
232
  </Alert.Root>
 
234
 
235
  <!-- USB Calibration Panel -->
236
  {#if showUSBCalibration}
237
+ <Card.Root
238
+ class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20"
239
+ >
240
  <Card.Header>
241
+ <div class="flex items-center justify-between">
242
  <Card.Title class="text-lg font-semibold text-orange-700 dark:text-orange-200">
243
  Hardware Calibration Required
244
  </Card.Title>
 
251
  </div>
252
  </Card.Header>
253
  <Card.Content class="space-y-4">
254
+ <Alert.Root
255
+ class="border-orange-300/30 bg-orange-100/10 dark:border-orange-500/30 dark:bg-orange-500/10"
256
+ >
257
+ <span class="icon-[mdi--information] size-4 text-orange-500 dark:text-orange-400"
258
+ ></span>
259
+ <Alert.Description class="text-sm text-orange-700 dark:text-orange-200">
260
+ Before connecting to the physical robot, calibration is required to map the servo
261
+ positions to software values. This ensures accurate control.
262
  </Alert.Description>
263
  </Alert.Root>
264
 
265
+ <USBCalibrationPanel
266
  calibrationManager={robot.calibrationManager}
267
  connectionType="producer"
268
  {onCalibrationComplete}
 
271
  </Card.Content>
272
  </Card.Root>
273
  {:else}
274
+ <!-- Current Status Overview -->
275
+ <Card.Root
276
+ class="border-blue-300/30 bg-blue-100/20 dark:border-blue-500/30 dark:bg-blue-900/20"
277
+ >
278
+ <Card.Content class="p-4">
279
+ <div class="flex items-center justify-between">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  <div class="flex items-center gap-2">
281
+ <span class="icon-[mdi--broadcast] size-4 text-blue-500 dark:text-blue-400"
282
+ ></span>
283
+ <span class="text-sm font-medium text-blue-700 dark:text-blue-300"
284
+ >Active Outputs</span
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  >
 
 
286
  </div>
287
+ <Badge variant="default" class="bg-blue-500 text-xs dark:bg-blue-600">
288
+ {outputDriverCount} Connected
289
+ </Badge>
290
  </div>
291
+ </Card.Content>
292
+ </Card.Root>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
+ <!-- Local Hardware Connection -->
295
+ <Card.Root
296
+ class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5"
297
+ >
298
  <Card.Header>
299
+ <Card.Title
300
+ class="flex items-center gap-2 text-base text-green-700 dark:text-green-200"
301
+ >
302
+ <span class="icon-[mdi--usb-port] size-4"></span>
303
+ Local Hardware (USB)
304
  </Card.Title>
305
+ <Card.Description class="text-xs text-green-600/70 dark:text-green-300/70">
306
+ Send commands directly to physical robot hardware
307
+ </Card.Description>
308
  </Card.Header>
309
+ <Card.Content class="space-y-3">
310
+ <Button
311
+ variant="secondary"
312
+ onclick={connectUSBOutput}
313
+ disabled={isConnecting}
314
+ class="w-full bg-green-500 text-sm text-white hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
315
+ >
316
+ <span class="icon-[mdi--usb] mr-2 size-4"></span>
317
+ {isConnecting ? "Connecting..." : "Add USB Output"}
318
+ </Button>
319
+ </Card.Content>
320
+ </Card.Root>
321
+
322
+ <!-- Remote Collaboration -->
323
+ <Card.Root
324
+ class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5"
325
+ >
326
+ <Card.Header>
327
+ <div class="flex items-center justify-between">
328
+ <div>
329
+ <Card.Title
330
+ class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200"
331
+ >
332
+ <span class="icon-[mdi--cloud-sync] size-4"></span>
333
+ Remote Collaboration (Rooms)
334
+ </Card.Title>
335
+ <Card.Description class="text-xs text-orange-600/70 dark:text-orange-300/70">
336
+ Broadcast robot movements to remote systems and AI
337
+ </Card.Description>
338
+ </div>
339
+ <Button
340
+ variant="ghost"
341
+ size="sm"
342
+ onclick={refreshRooms}
343
+ disabled={robotManager.roomsLoading || isConnecting}
344
+ class="h-7 px-2 text-xs text-orange-700 hover:bg-orange-200/20 hover:text-orange-800 dark:text-orange-300 dark:hover:bg-orange-500/20 dark:hover:text-orange-200"
345
+ >
346
+ {#if robotManager.roomsLoading}
347
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
348
+ Refreshing
349
+ {:else}
350
+ <span class="icon-[mdi--refresh] mr-1 size-3"></span>
351
+ Refresh
352
+ {/if}
353
+ </Button>
354
+ </div>
355
+ </Card.Header>
356
+ <Card.Content class="space-y-4">
357
+ <!-- Create New Room -->
358
+ <div
359
+ class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5"
360
+ >
361
+ <div class="space-y-2">
362
+ <div class="flex items-center gap-2">
363
+ <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"
364
+ ></span>
365
+ <p class="text-sm font-medium text-green-700 dark:text-green-300">
366
+ Create New Room
367
+ </p>
368
+ </div>
369
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
370
+ Create a room to broadcast this robot's movements
371
+ </p>
372
+ <input
373
+ bind:value={customRoomId}
374
+ placeholder={`Room ID (default: ${robot.id})`}
375
+ disabled={isConnecting}
376
+ class="w-full rounded border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-900 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
377
+ />
378
+ <div class="flex gap-1">
379
+ <Button
380
+ variant="secondary"
381
+ size="sm"
382
+ onclick={createRoom}
383
+ disabled={isConnecting}
384
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
385
+ >
386
+ Create Only
387
+ </Button>
388
  <Button
389
+ variant="secondary"
390
  size="sm"
391
+ onclick={createRoomAndJoinAsOutput}
392
  disabled={isConnecting}
393
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
394
  >
395
+ Create & Join as Output
396
  </Button>
397
  </div>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- Existing Rooms -->
402
+ <div class="space-y-2">
403
+ <div class="flex items-center justify-between">
404
+ <span class="text-xs font-medium text-orange-700 dark:text-orange-300"
405
+ >Join Existing Room:</span
406
+ >
407
+ <span class="text-xs text-slate-600 dark:text-slate-400">
408
+ {robotManager.rooms.length} room{robotManager.rooms.length !== 1 ? "s" : ""} available
409
+ </span>
410
+ </div>
411
+
412
+ <div class="max-h-40 space-y-2 overflow-y-auto">
413
+ {#if robotManager.rooms.length === 0}
414
+ <div class="py-3 text-center text-xs text-slate-600 dark:text-slate-400">
415
+ {robotManager.roomsLoading
416
+ ? "Loading rooms..."
417
+ : "No rooms available. Create one to get started."}
418
+ </div>
419
+ {:else}
420
+ {#each robotManager.rooms as room}
421
+ <div
422
+ class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50"
423
+ >
424
+ <div class="flex items-start justify-between gap-3">
425
+ <div class="min-w-0 flex-1">
426
+ <p
427
+ class="truncate text-xs font-medium text-slate-800 dark:text-slate-200"
428
+ >
429
+ {room.id}
430
+ </p>
431
+ <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
432
+ <span>{room.has_producer ? "🔴 Occupied" : "🟢 Available"}</span>
433
+ <span>👥 {room.participants?.total || 0} users</span>
434
+ </div>
435
+ </div>
436
+ {#if !room.has_producer}
437
+ <Button
438
+ variant="secondary"
439
+ size="sm"
440
+ onclick={() => {
441
+ selectedRoomId = room.id;
442
+ joinRoomAsOutput();
443
+ }}
444
+ disabled={isConnecting}
445
+ class="h-6 shrink-0 bg-orange-500 px-2 text-xs hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700"
446
+ >
447
+ <span class="icon-[mdi--login] mr-1 size-3"></span>
448
+ Join as Output
449
+ </Button>
450
+ {:else}
451
+ <Button
452
+ variant="ghost"
453
+ size="sm"
454
+ disabled
455
+ class="h-6 shrink-0 px-2 text-xs opacity-50"
456
+ >
457
+ Occupied
458
+ </Button>
459
+ {/if}
460
+ </div>
461
+ </div>
462
+ {/each}
463
+ {/if}
464
+ </div>
465
  </div>
466
  </Card.Content>
467
  </Card.Root>
 
468
 
469
+ <!-- Connected Outputs -->
470
+ {#if producers.length > 0}
471
+ <Card.Root
472
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
473
+ >
474
+ <Card.Header>
475
+ <Card.Title
476
+ class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200"
477
+ >
478
+ <span class="icon-[mdi--connection] size-4"></span>
479
+ Connected Outputs
480
+ </Card.Title>
481
+ </Card.Header>
482
+ <Card.Content>
483
+ <div class="max-h-32 space-y-2 overflow-y-auto">
484
+ {#each producers as producer}
485
+ <div
486
+ class="flex items-center justify-between rounded-md bg-slate-100/50 p-2 dark:bg-slate-700/50"
487
+ >
488
+ <div class="flex items-center gap-2">
489
+ <span
490
+ class="size-2 rounded-full {producer.status.isConnected
491
+ ? 'bg-green-500 dark:bg-green-400'
492
+ : 'bg-red-500 dark:bg-red-400'}"
493
+ ></span>
494
+ <span class="text-sm text-slate-700 dark:text-slate-300"
495
+ >{producer.name}</span
496
+ >
497
+ <Badge variant="secondary" class="text-xs">{producer.id.slice(0, 12)}</Badge
498
+ >
499
+ </div>
500
+ <Button
501
+ variant="destructive"
502
+ size="sm"
503
+ onclick={() => disconnectOutput(producer.id)}
504
+ disabled={isConnecting}
505
+ class="h-6 px-2 text-xs"
506
+ >
507
+ <span class="icon-[mdi--close] size-3"></span>
508
+ </Button>
509
+ </div>
510
+ {/each}
511
+ </div>
512
+ </Card.Content>
513
+ </Card.Root>
514
+ {/if}
515
+
516
+ <!-- Help Information -->
517
+ <Alert.Root
518
+ class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30"
519
+ >
520
+ <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
521
+ <Alert.Title class="text-slate-700 dark:text-slate-300">Output Sources</Alert.Title>
522
+ <Alert.Description class="text-xs text-slate-600 dark:text-slate-400">
523
+ <strong>USB:</strong> Control physical hardware • <strong>Remote:</strong> Broadcast to
524
+ network • Multiple outputs can be active
525
+ </Alert.Description>
526
+ </Alert.Root>
527
  {/if}
528
  </div>
529
  </div>
530
  </Dialog.Content>
531
+ </Dialog.Root>
src/lib/components/3d/elements/robot/status/ConnectionFlowBoxUIkit.svelte CHANGED
@@ -15,27 +15,16 @@
15
 
16
  let { robot, onInputBoxClick, onRobotBoxClick, onOutputBoxClick }: Props = $props();
17
 
18
- // Colors
19
  const inputColor = "rgb(34, 197, 94)";
20
  const outputColor = "rgb(59, 130, 246)";
21
  </script>
22
 
23
- <!--
24
- @component
25
- Connection flow layout showing the robot control flow from input to robot to outputs.
26
- Uses consistent arrow theming and spacing.
27
- -->
28
-
29
  <Container flexDirection="row" alignItems="center" gap={12}>
30
  <!-- Input Box -->
31
  <InputBoxUIKit {robot} {onInputBoxClick} />
32
 
33
  <!-- Arrow 1: Input to Robot -->
34
- <StatusArrow
35
- direction="right"
36
- color={inputColor}
37
- opacity={robot.hasConsumer ? 1 : 0.5}
38
- />
39
 
40
  <!-- Robot Box -->
41
  <RobotBoxUIKit {robot} {onRobotBoxClick} />
 
15
 
16
  let { robot, onInputBoxClick, onRobotBoxClick, onOutputBoxClick }: Props = $props();
17
 
 
18
  const inputColor = "rgb(34, 197, 94)";
19
  const outputColor = "rgb(59, 130, 246)";
20
  </script>
21
 
 
 
 
 
 
 
22
  <Container flexDirection="row" alignItems="center" gap={12}>
23
  <!-- Input Box -->
24
  <InputBoxUIKit {robot} {onInputBoxClick} />
25
 
26
  <!-- Arrow 1: Input to Robot -->
27
+ <StatusArrow direction="right" color={inputColor} opacity={robot.hasConsumer ? 1 : 0.5} />
 
 
 
 
28
 
29
  <!-- Robot Box -->
30
  <RobotBoxUIKit {robot} {onRobotBoxClick} />
src/lib/components/3d/elements/robot/status/InputBoxUIKit.svelte CHANGED
@@ -1,10 +1,10 @@
1
  <script lang="ts">
2
  import type { Robot } from "$lib/elements/robot/Robot.svelte.js";
3
  import { ICON } from "$lib/utils/icon";
4
- import {
5
- BaseStatusBox,
6
- StatusHeader,
7
- StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
@@ -21,16 +21,9 @@
21
  onInputBoxClick(robot);
22
  }
23
 
24
- // Input theme color (green)
25
  const inputColor = "rgb(34, 197, 94)";
26
  </script>
27
 
28
- <!--
29
- @component
30
- Input connection box showing the status of the input connection.
31
- Displays input information when connected or connection prompt when disconnected.
32
- -->
33
-
34
  <BaseStatusBox
35
  color={inputColor}
36
  borderOpacity={robot.hasConsumer ? 0.8 : 0.4}
@@ -47,15 +40,12 @@ Displays input information when connected or connection prompt when disconnected
47
  opacity={0.9}
48
  />
49
 
50
- <StatusContent
51
- title={robot.consumer?.name.slice(0, 30) ?? 'No Input'}
52
- color={inputColor}
53
- />
54
 
55
- {#if robot.consumer?.constructor.name}
56
- <Text
57
- text={robot.consumer.constructor.name.replace("Driver", "").slice(0, 30)}
58
- fontSize={11}
59
  fontWeight="normal"
60
  color="rgb(134, 239, 172)"
61
  opacity={0.9}
@@ -87,11 +77,7 @@ Displays input information when connected or connection prompt when disconnected
87
  fontSize={13}
88
  />
89
 
90
- <StatusContent
91
- title="Click to Connect"
92
- color={inputColor}
93
- variant="secondary"
94
- />
95
 
96
  <StatusButton
97
  text="Add Input"
@@ -101,4 +87,4 @@ Displays input information when connected or connection prompt when disconnected
101
  textOpacity={0.7}
102
  />
103
  {/if}
104
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import type { Robot } from "$lib/elements/robot/Robot.svelte.js";
3
  import { ICON } from "$lib/utils/icon";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
  StatusIndicator,
9
  StatusButton
10
  } from "$lib/components/3d/ui";
 
21
  onInputBoxClick(robot);
22
  }
23
 
 
24
  const inputColor = "rgb(34, 197, 94)";
25
  </script>
26
 
 
 
 
 
 
 
27
  <BaseStatusBox
28
  color={inputColor}
29
  borderOpacity={robot.hasConsumer ? 0.8 : 0.4}
 
40
  opacity={0.9}
41
  />
42
 
43
+ <StatusContent title={robot.consumer?.name.slice(0, 30) ?? "No Input"} color={inputColor} />
 
 
 
44
 
45
+ {#if robot.consumer?.constructor.name}
46
+ <Text
47
+ text={robot.consumer.constructor.name.replace("Driver", "").slice(0, 30)}
48
+ fontSize={11}
49
  fontWeight="normal"
50
  color="rgb(134, 239, 172)"
51
  opacity={0.9}
 
77
  fontSize={13}
78
  />
79
 
80
+ <StatusContent title="Click to Connect" color={inputColor} variant="secondary" />
 
 
 
 
81
 
82
  <StatusButton
83
  text="Add Input"
 
87
  textOpacity={0.7}
88
  />
89
  {/if}
90
+ </BaseStatusBox>
src/lib/components/3d/elements/robot/status/ManualControlBoxUIKit.svelte CHANGED
@@ -18,11 +18,10 @@
18
  }
19
  }
20
 
21
- // Manual control theme color (purple)
22
  const manualColor = "rgb(147, 51, 234)";
23
  </script>
24
 
25
- <BaseStatusBox
26
  color={manualColor}
27
  borderOpacity={isDisabled ? 0.3 : robot.isManualControlEnabled ? 0.8 : 0.5}
28
  backgroundOpacity={isDisabled ? 0.1 : robot.isManualControlEnabled ? 0.3 : 0.2}
@@ -31,29 +30,29 @@
31
  >
32
  {#if isDisabled}
33
  <!-- Disabled State -->
34
- <StatusHeader
35
- icon={ICON["icon-[material-symbols--lock]"].svg}
36
- text="DISABLED"
37
  color={manualColor}
38
  opacity={0.7}
39
  />
40
 
41
- <StatusContent
42
- title="Control managed by"
43
- subtitle={robot.consumer?.name.slice(0, 20) ?? 'No Input'}
44
- color="rgb(196, 181, 253)"
45
  variant="secondary"
46
  />
47
  {:else if robot.isManualControlEnabled}
48
  <!-- Enabled State -->
49
- <StatusHeader
50
- icon={ICON["icon-[solar--volume-knob-bold]"].svg}
51
- text="ACTIVE"
52
  color={manualColor}
53
  opacity={0.9}
54
  />
55
 
56
- <StatusContent
57
  title={`${robot.jointArray.length} Joints Active`}
58
  subtitle="Manual Control Enabled"
59
  color="rgb(233, 213, 255)"
@@ -61,20 +60,16 @@
61
  />
62
  {:else}
63
  <!-- Default State (Manual Off) -->
64
- <StatusHeader
65
- icon={ICON["icon-[solar--volume-knob-bold]"].svg}
66
- text="MANUAL"
67
  color={manualColor}
68
  opacity={0.8}
69
  />
70
 
71
- <StatusContent
72
- title="Click to Enable"
73
- color={manualColor}
74
- variant="secondary"
75
- />
76
 
77
- <StatusButton
78
  icon={ICON["icon-[mingcute--settings-2-fill]"].svg}
79
  text="Configure"
80
  color={manualColor}
 
18
  }
19
  }
20
 
 
21
  const manualColor = "rgb(147, 51, 234)";
22
  </script>
23
 
24
+ <BaseStatusBox
25
  color={manualColor}
26
  borderOpacity={isDisabled ? 0.3 : robot.isManualControlEnabled ? 0.8 : 0.5}
27
  backgroundOpacity={isDisabled ? 0.1 : robot.isManualControlEnabled ? 0.3 : 0.2}
 
30
  >
31
  {#if isDisabled}
32
  <!-- Disabled State -->
33
+ <StatusHeader
34
+ icon={ICON["icon-[material-symbols--lock]"].svg}
35
+ text="DISABLED"
36
  color={manualColor}
37
  opacity={0.7}
38
  />
39
 
40
+ <StatusContent
41
+ title="Control managed by"
42
+ subtitle={robot.consumer?.name.slice(0, 20) ?? "No Input"}
43
+ color="rgb(196, 181, 253)"
44
  variant="secondary"
45
  />
46
  {:else if robot.isManualControlEnabled}
47
  <!-- Enabled State -->
48
+ <StatusHeader
49
+ icon={ICON["icon-[solar--volume-knob-bold]"].svg}
50
+ text="ACTIVE"
51
  color={manualColor}
52
  opacity={0.9}
53
  />
54
 
55
+ <StatusContent
56
  title={`${robot.jointArray.length} Joints Active`}
57
  subtitle="Manual Control Enabled"
58
  color="rgb(233, 213, 255)"
 
60
  />
61
  {:else}
62
  <!-- Default State (Manual Off) -->
63
+ <StatusHeader
64
+ icon={ICON["icon-[solar--volume-knob-bold]"].svg}
65
+ text="MANUAL"
66
  color={manualColor}
67
  opacity={0.8}
68
  />
69
 
70
+ <StatusContent title="Click to Enable" color={manualColor} variant="secondary" />
 
 
 
 
71
 
72
+ <StatusButton
73
  icon={ICON["icon-[mingcute--settings-2-fill]"].svg}
74
  text="Configure"
75
  color={manualColor}
src/lib/components/3d/elements/robot/status/OutputBoxUIKit.svelte CHANGED
@@ -16,15 +16,13 @@
16
 
17
  // Output theme color (blue)
18
  const outputColor = "rgb(59, 130, 246)";
19
-
20
-
21
  </script>
22
 
23
  <!--
24
  Event info here
25
  https://github.com/threlte/threlte-uikit/blob/13b34172656cd49b9e2f38d7a9c41305e435daa1/src/lib/Events.ts#L30
26
  -->
27
- <BaseStatusBox
28
  color={outputColor}
29
  borderOpacity={robot.outputDriverCount > 0 ? 0.8 : 0.4}
30
  backgroundOpacity={robot.outputDriverCount > 0 ? 0.3 : 0.15}
@@ -33,15 +31,15 @@
33
  >
34
  {#if robot.outputDriverCount > 0}
35
  <!-- Connected Outputs State -->
36
- <StatusHeader
37
- icon={ICON["icon-[material-symbols--upload]"].svg}
38
- text="OUTPUT"
39
  color={outputColor}
40
  opacity={0.9}
41
  />
42
 
43
  <!-- Outputs Count -->
44
- <StatusContent
45
  title={`${robot.outputDriverCount} Outputs Active`}
46
  color="rgb(191, 219, 254)"
47
  variant="primary"
@@ -49,13 +47,10 @@
49
 
50
  {#if robot.producers.length > 0}
51
  <!-- Outputs List -->
52
- <StatusContent
53
- color={outputColor}
54
- variant="secondary"
55
- >
56
  {#snippet children()}
57
  {#each robot.producers.slice(0, 2) as producer}
58
- <StatusContent
59
  title={producer.name.slice(0, 20)}
60
  subtitle={producer.constructor.name.replace("Producer", "").slice(0, 15)}
61
  color={outputColor}
@@ -65,7 +60,7 @@
65
  {/each}
66
 
67
  {#if robot.producers.length > 2}
68
- <StatusContent
69
  title={`+${robot.producers.length - 2} more`}
70
  color={outputColor}
71
  variant="tertiary"
@@ -77,24 +72,20 @@
77
  {/if}
78
  {:else}
79
  <!-- No Outputs State -->
80
- <StatusHeader
81
- icon={ICON["icon-[material-symbols--upload]"].svg}
82
- text="NO OUTPUT"
83
  color={outputColor}
84
  opacity={0.7}
85
  />
86
 
87
- <StatusContent
88
- title="Click to Connect"
89
- color={outputColor}
90
- variant="secondary"
91
- />
92
 
93
- <StatusButton
94
  icon={ICON["icon-[mdi--plus]"].svg}
95
  text="Add Outputs"
96
  color={outputColor}
97
  textOpacity={0.7}
98
  />
99
  {/if}
100
- </BaseStatusBox>
 
16
 
17
  // Output theme color (blue)
18
  const outputColor = "rgb(59, 130, 246)";
 
 
19
  </script>
20
 
21
  <!--
22
  Event info here
23
  https://github.com/threlte/threlte-uikit/blob/13b34172656cd49b9e2f38d7a9c41305e435daa1/src/lib/Events.ts#L30
24
  -->
25
+ <BaseStatusBox
26
  color={outputColor}
27
  borderOpacity={robot.outputDriverCount > 0 ? 0.8 : 0.4}
28
  backgroundOpacity={robot.outputDriverCount > 0 ? 0.3 : 0.15}
 
31
  >
32
  {#if robot.outputDriverCount > 0}
33
  <!-- Connected Outputs State -->
34
+ <StatusHeader
35
+ icon={ICON["icon-[material-symbols--upload]"].svg}
36
+ text="OUTPUT"
37
  color={outputColor}
38
  opacity={0.9}
39
  />
40
 
41
  <!-- Outputs Count -->
42
+ <StatusContent
43
  title={`${robot.outputDriverCount} Outputs Active`}
44
  color="rgb(191, 219, 254)"
45
  variant="primary"
 
47
 
48
  {#if robot.producers.length > 0}
49
  <!-- Outputs List -->
50
+ <StatusContent color={outputColor} variant="secondary">
 
 
 
51
  {#snippet children()}
52
  {#each robot.producers.slice(0, 2) as producer}
53
+ <StatusContent
54
  title={producer.name.slice(0, 20)}
55
  subtitle={producer.constructor.name.replace("Producer", "").slice(0, 15)}
56
  color={outputColor}
 
60
  {/each}
61
 
62
  {#if robot.producers.length > 2}
63
+ <StatusContent
64
  title={`+${robot.producers.length - 2} more`}
65
  color={outputColor}
66
  variant="tertiary"
 
72
  {/if}
73
  {:else}
74
  <!-- No Outputs State -->
75
+ <StatusHeader
76
+ icon={ICON["icon-[material-symbols--upload]"].svg}
77
+ text="NO OUTPUT"
78
  color={outputColor}
79
  opacity={0.7}
80
  />
81
 
82
+ <StatusContent title="Click to Connect" color={outputColor} variant="secondary" />
 
 
 
 
83
 
84
+ <StatusButton
85
  icon={ICON["icon-[mdi--plus]"].svg}
86
  text="Add Outputs"
87
  color={outputColor}
88
  textOpacity={0.7}
89
  />
90
  {/if}
91
+ </BaseStatusBox>
src/lib/components/3d/elements/robot/status/RobotBoxUIKit.svelte CHANGED
@@ -15,7 +15,6 @@
15
 
16
  let { robot, onRobotBoxClick }: Props = $props();
17
 
18
- // Robot theme color (orange)
19
  const robotColor = "rgb(245, 158, 11)";
20
  </script>
21
 
 
15
 
16
  let { robot, onRobotBoxClick }: Props = $props();
17
 
 
18
  const robotColor = "rgb(245, 158, 11)";
19
  </script>
20
 
src/lib/components/3d/elements/video/Video.svelte CHANGED
@@ -1,7 +1,16 @@
1
  <script lang="ts">
2
  import { T } from "@threlte/core";
3
  import { interactivity } from "@threlte/extras";
4
- import { VideoTexture, CanvasTexture, LinearFilter, RGBAFormat, Shape, Path, ExtrudeGeometry, BoxGeometry } from "three";
 
 
 
 
 
 
 
 
 
5
  import { onMount } from "svelte";
6
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
7
  import { videoManager } from "$lib/elements/video/VideoManager.svelte";
@@ -10,7 +19,7 @@
10
  interface Props {
11
  // Video instance (required)
12
  videoInstance: VideoInstance;
13
-
14
  // Workspace ID (required for API calls)
15
  workspaceId: string;
16
 
@@ -82,45 +91,45 @@
82
 
83
  // Function to create loading texture with text
84
  const createLoadingTexture = (text: string, backgroundColor: string = fallbackColor) => {
85
- const canvas = document.createElement('canvas');
86
- const ctx = canvas.getContext('2d')!;
87
-
88
  // Set canvas size (should match video aspect ratio)
89
  canvas.width = 512;
90
  canvas.height = 288; // 16:9 aspect ratio
91
-
92
  // Fill background
93
  ctx.fillStyle = backgroundColor;
94
  ctx.fillRect(0, 0, canvas.width, canvas.height);
95
-
96
  // Setup text styling
97
- ctx.fillStyle = '#FFFFFF';
98
- ctx.textAlign = 'center';
99
- ctx.textBaseline = 'middle';
100
- ctx.font = 'bold 32px Arial, sans-serif';
101
-
102
  // Add text shadow for better readability
103
- ctx.shadowColor = 'rgba(0, 0, 0, 0.7)';
104
  ctx.shadowBlur = 4;
105
  ctx.shadowOffsetX = 2;
106
  ctx.shadowOffsetY = 2;
107
-
108
  // Draw the loading text
109
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
110
-
111
  // Draw emoji if provided
112
  if (loadingEmoji) {
113
- ctx.font = 'bold 48px Arial, sans-serif';
114
- ctx.shadowColor = 'transparent';
115
  ctx.fillText(loadingEmoji, canvas.width / 2, canvas.height / 2 - 60);
116
  }
117
-
118
  // Create and return Three.js texture
119
  const texture = new CanvasTexture(canvas);
120
  texture.minFilter = LinearFilter;
121
  texture.magFilter = LinearFilter;
122
  texture.needsUpdate = true;
123
-
124
  return texture;
125
  };
126
 
@@ -224,7 +233,10 @@
224
  }
225
 
226
  // Cache status values to prevent reactive loops
227
- const canActivate = lazyLoad && (videoInstance.input.connectionState === 'prepared' || videoInstance.input.connectionState === 'paused');
 
 
 
228
  const hasPreparedRoom = videoInstance.input.preparedRoomId !== null;
229
 
230
  // Add a small delay to avoid loading on quick hovers
@@ -261,7 +273,10 @@
261
  cleanupVideo();
262
 
263
  // Cache status values to prevent reactive loops
264
- const canPause = lazyLoad && videoInstance.input.connectionState === 'connected' && videoInstance.input.connectionPolicy === 'lazy';
 
 
 
265
 
266
  // Only pause remote streams with lazy policy (not persistent connections)
267
  if (canPause) {
@@ -283,11 +298,11 @@
283
  }
284
 
285
  const currentStream = videoInstance.currentStream;
286
-
287
  // Only update if the stream reference has actually changed
288
  if (currentStream !== lastStreamRef) {
289
  lastStreamRef = currentStream;
290
-
291
  // Gracefully stop current video
292
  try {
293
  videoElement.pause();
 
1
  <script lang="ts">
2
  import { T } from "@threlte/core";
3
  import { interactivity } from "@threlte/extras";
4
+ import {
5
+ VideoTexture,
6
+ CanvasTexture,
7
+ LinearFilter,
8
+ RGBAFormat,
9
+ Shape,
10
+ Path,
11
+ ExtrudeGeometry,
12
+ BoxGeometry
13
+ } from "three";
14
  import { onMount } from "svelte";
15
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
16
  import { videoManager } from "$lib/elements/video/VideoManager.svelte";
 
19
  interface Props {
20
  // Video instance (required)
21
  videoInstance: VideoInstance;
22
+
23
  // Workspace ID (required for API calls)
24
  workspaceId: string;
25
 
 
91
 
92
  // Function to create loading texture with text
93
  const createLoadingTexture = (text: string, backgroundColor: string = fallbackColor) => {
94
+ const canvas = document.createElement("canvas");
95
+ const ctx = canvas.getContext("2d")!;
96
+
97
  // Set canvas size (should match video aspect ratio)
98
  canvas.width = 512;
99
  canvas.height = 288; // 16:9 aspect ratio
100
+
101
  // Fill background
102
  ctx.fillStyle = backgroundColor;
103
  ctx.fillRect(0, 0, canvas.width, canvas.height);
104
+
105
  // Setup text styling
106
+ ctx.fillStyle = "#FFFFFF";
107
+ ctx.textAlign = "center";
108
+ ctx.textBaseline = "middle";
109
+ ctx.font = "bold 32px Arial, sans-serif";
110
+
111
  // Add text shadow for better readability
112
+ ctx.shadowColor = "rgba(0, 0, 0, 0.7)";
113
  ctx.shadowBlur = 4;
114
  ctx.shadowOffsetX = 2;
115
  ctx.shadowOffsetY = 2;
116
+
117
  // Draw the loading text
118
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
119
+
120
  // Draw emoji if provided
121
  if (loadingEmoji) {
122
+ ctx.font = "bold 48px Arial, sans-serif";
123
+ ctx.shadowColor = "transparent";
124
  ctx.fillText(loadingEmoji, canvas.width / 2, canvas.height / 2 - 60);
125
  }
126
+
127
  // Create and return Three.js texture
128
  const texture = new CanvasTexture(canvas);
129
  texture.minFilter = LinearFilter;
130
  texture.magFilter = LinearFilter;
131
  texture.needsUpdate = true;
132
+
133
  return texture;
134
  };
135
 
 
233
  }
234
 
235
  // Cache status values to prevent reactive loops
236
+ const canActivate =
237
+ lazyLoad &&
238
+ (videoInstance.input.connectionState === "prepared" ||
239
+ videoInstance.input.connectionState === "paused");
240
  const hasPreparedRoom = videoInstance.input.preparedRoomId !== null;
241
 
242
  // Add a small delay to avoid loading on quick hovers
 
273
  cleanupVideo();
274
 
275
  // Cache status values to prevent reactive loops
276
+ const canPause =
277
+ lazyLoad &&
278
+ videoInstance.input.connectionState === "connected" &&
279
+ videoInstance.input.connectionPolicy === "lazy";
280
 
281
  // Only pause remote streams with lazy policy (not persistent connections)
282
  if (canPause) {
 
298
  }
299
 
300
  const currentStream = videoInstance.currentStream;
301
+
302
  // Only update if the stream reference has actually changed
303
  if (currentStream !== lastStreamRef) {
304
  lastStreamRef = currentStream;
305
+
306
  // Gracefully stop current video
307
  try {
308
  videoElement.pause();
src/lib/components/3d/elements/video/VideoGridItem.svelte CHANGED
@@ -13,13 +13,7 @@
13
  onOutputBoxClick: (video: VideoInstance) => void;
14
  }
15
 
16
- let {
17
- video,
18
- workspaceId,
19
- onCameraMove,
20
- onInputBoxClick,
21
- onOutputBoxClick
22
- }: Props = $props();
23
 
24
  const { onPointerEnter, onPointerLeave } = useCursor();
25
  interactivity();
@@ -35,7 +29,7 @@
35
 
36
  <T.Group
37
  position.x={video.position.x}
38
- position.y={video.position.y+1}
39
  position.z={video.position.z}
40
  scale={[1, 1, 1]}
41
  >
 
13
  onOutputBoxClick: (video: VideoInstance) => void;
14
  }
15
 
16
+ let { video, workspaceId, onCameraMove, onInputBoxClick, onOutputBoxClick }: Props = $props();
 
 
 
 
 
 
17
 
18
  const { onPointerEnter, onPointerLeave } = useCursor();
19
  interactivity();
 
29
 
30
  <T.Group
31
  position.x={video.position.x}
32
+ position.y={video.position.y + 1}
33
  position.z={video.position.z}
34
  scale={[1, 1, 1]}
35
  >
src/lib/components/3d/elements/video/Videos.svelte CHANGED
@@ -11,7 +11,7 @@
11
  interface Props {
12
  workspaceId: string;
13
  }
14
- let {workspaceId}: Props = $props();
15
 
16
  // Modal state
17
  let isInputModalOpen = $state(false);
@@ -34,8 +34,8 @@
34
  {video}
35
  {workspaceId}
36
  onCameraMove={() => {}}
37
- onInputBoxClick={onInputBoxClick}
38
- onOutputBoxClick={onOutputBoxClick}
39
  />
40
  {/each}
41
 
 
11
  interface Props {
12
  workspaceId: string;
13
  }
14
+ let { workspaceId }: Props = $props();
15
 
16
  // Modal state
17
  let isInputModalOpen = $state(false);
 
34
  {video}
35
  {workspaceId}
36
  onCameraMove={() => {}}
37
+ {onInputBoxClick}
38
+ {onOutputBoxClick}
39
  />
40
  {/each}
41
 
src/lib/components/3d/elements/video/modal/VideoInputConnectionModal.svelte CHANGED
@@ -16,152 +16,156 @@
16
 
17
  let { open = $bindable(), video, workspaceId }: Props = $props();
18
 
19
- let isConnecting = $state(false);
20
- let error = $state<string | null>(null);
21
- let customRoomId = $state('');
22
- let hasLoadedRooms = $state(false);
23
 
24
- // Auto-load rooms when modal opens (only once per modal session)
25
- $effect(() => {
26
- if (open && !hasLoadedRooms && !videoManager.roomsLoading) {
27
- refreshRooms();
28
- hasLoadedRooms = true;
29
- }
30
-
31
- // Reset when modal closes
32
- if (!open) {
33
- hasLoadedRooms = false;
34
- error = null;
35
- }
36
- });
37
 
38
- async function refreshRooms() {
39
- try {
40
- error = null;
41
- await videoManager.refreshRooms(workspaceId);
42
- } catch (err) {
43
- error = err instanceof Error ? err.message : 'Failed to refresh rooms';
44
- }
45
- }
46
 
47
- async function handleConnectCamera() {
48
- try {
49
- isConnecting = true;
50
- error = null;
51
-
52
- const result = await videoManager.connectLocalCamera(video.id);
53
- if (result.success) {
54
- toast.success("Camera Connected", {
55
- description: "Successfully connected to local camera"
56
- });
57
- } else {
58
- error = result.error || 'Failed to connect camera';
59
- toast.error("Camera Connection Failed", {
60
- description: error
61
- });
62
- }
63
- } catch (err) {
64
- error = err instanceof Error ? err.message : 'Failed to connect camera';
65
- toast.error("Camera Connection Error", {
66
- description: error
67
- });
68
- } finally {
69
- isConnecting = false;
70
- }
71
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- async function handleConnectToRoom(roomId: string) {
74
- try {
75
- isConnecting = true;
76
- error = null;
77
- const result = await videoManager.connectRemoteStream(workspaceId, video.id, roomId);
78
- if (result.success) {
79
- toast.success("Connected to Room", {
80
- description: `Successfully connected to room ${roomId} - receiving video stream`
81
- });
82
- } else {
83
- error = result.error || 'Failed to connect to room';
84
- }
85
- } catch (err) {
86
- error = err instanceof Error ? err.message : 'Failed to connect to room';
87
- } finally {
88
- isConnecting = false;
89
- }
90
- }
 
 
 
 
91
 
92
- async function createRoom() {
93
- try {
94
- isConnecting = true;
95
- error = null;
96
- const roomId = customRoomId.trim() || video.id;
97
- const result = await videoManager.createVideoRoom(workspaceId, roomId);
98
-
99
- if (result.success) {
100
- customRoomId = '';
101
- await refreshRooms();
102
- toast.success("Room Created", {
103
- description: `Successfully created room ${result.roomId}`
104
- });
105
- } else {
106
- error = result.error || 'Failed to create room';
107
- }
108
- } catch (err) {
109
- error = err instanceof Error ? err.message : 'Failed to create room';
110
- } finally {
111
- isConnecting = false;
112
- }
113
- }
 
 
 
114
 
115
- async function createRoomAndConnect() {
116
- try {
117
- isConnecting = true;
118
- error = null;
119
- const roomId = customRoomId.trim() || video.id;
120
- const createResult = await videoManager.createVideoRoom(workspaceId, roomId);
121
-
122
- if (!createResult.success) {
123
- error = createResult.error || 'Failed to create room';
124
- return;
125
- }
126
-
127
- const connectResult = await videoManager.connectRemoteStream(workspaceId, video.id, createResult.roomId!);
128
- if (connectResult.success) {
129
- customRoomId = '';
130
- await refreshRooms();
131
- toast.success("Room Created & Connected", {
132
- description: `Successfully created and connected to room ${createResult.roomId} - receiving video stream`
133
- });
134
- } else {
135
- error = connectResult.error || 'Failed to connect to room';
136
- }
137
- } catch (err) {
138
- error = err instanceof Error ? err.message : 'Failed to create room and connect';
139
- } finally {
140
- isConnecting = false;
141
- }
142
- }
143
 
144
- async function handleDisconnect() {
145
- try {
146
- isConnecting = true;
147
- error = null;
148
- await videoManager.disconnectVideoInput(video.id);
149
-
150
- // Small delay to ensure reactive state updates
151
- await new Promise(resolve => setTimeout(resolve, 100));
152
-
153
- toast.success("Video Input Disconnected", {
154
- description: "Successfully disconnected video input"
155
- });
156
- } catch (err) {
157
- error = err instanceof Error ? err.message : 'Failed to disconnect video input';
158
- toast.error("Disconnect Failed", {
159
- description: error
160
- });
161
- } finally {
162
- isConnecting = false;
163
- }
164
- }
165
  </script>
166
 
167
  <Dialog.Root bind:open>
@@ -169,9 +173,12 @@
169
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
170
  >
171
  <Dialog.Header class="pb-3">
172
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
173
- <span class="icon-[mdi--video-input-component] size-5 text-green-500 dark:text-green-400"></span>
174
- Video Input - {video?.name || 'No Video Selected'}
 
 
 
175
  </Dialog.Title>
176
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
177
  Configure video input source: local camera for recording or remote streams from rooms
@@ -182,304 +189,358 @@
182
  <div class="space-y-4 pb-4">
183
  <!-- Error display -->
184
  {#if error}
185
- <Alert.Root class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20">
 
 
186
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
187
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
188
- <Alert.Description class="text-red-600 text-sm dark:text-red-400">
189
  {error}
190
  </Alert.Description>
191
  </Alert.Root>
192
  {/if}
193
- <!-- Current Status Overview -->
194
- <Card.Root class="border-green-300/30 bg-green-100/20 dark:border-green-500/30 dark:bg-green-900/20">
195
- <Card.Content class="p-4">
196
- <div class="flex items-center justify-between">
197
- <div class="flex items-center gap-2">
198
- <span class="icon-[mdi--video-input-component] size-4 text-green-500 dark:text-green-400"></span>
199
- <span class="text-sm font-medium text-green-700 dark:text-green-300">Current Video Input</span>
200
- </div>
201
- {#if video?.hasInput}
202
- <Badge variant="default" class="bg-green-500 text-xs dark:bg-green-600">
203
- {video.input.type === 'local-camera' ? 'Local Camera' : 'Remote Stream'}
204
- </Badge>
205
- {:else}
206
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Input Connected</Badge>
207
- {/if}
208
- </div>
209
- {#if video?.hasInput}
210
- <div class="mt-2 text-xs text-green-600/70 dark:text-green-400/70">
211
- {#if video.input.roomId}
212
- Room: {video.input.roomId}
213
  {:else}
214
- Source: Local Device Camera
 
 
215
  {/if}
216
  </div>
217
- {/if}
218
- </Card.Content>
219
- </Card.Root>
 
 
 
 
 
 
 
 
220
 
221
- <!-- Current Input Details -->
222
- {#if video?.hasInput}
223
- <Card.Root class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  <Card.Header>
225
- <Card.Title class="flex items-center gap-2 text-base text-green-700 dark:text-green-200">
226
- <span class="icon-[mdi--video] size-4"></span>
227
- Current Input
228
  </Card.Title>
 
 
 
229
  </Card.Header>
230
- <Card.Content>
231
- <div class="rounded-lg border border-green-300/30 bg-green-100/20 p-3 dark:border-green-500/30 dark:bg-green-900/20">
232
- <div class="flex items-center justify-between">
233
- <div>
234
- <p class="text-sm font-medium text-green-700 dark:text-green-300">
235
- {video.input.type === 'local-camera' ? 'Local Camera' : 'Remote Stream'}
236
- </p>
237
- {#if video.input.roomId}
238
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
239
- Room: {video.input.roomId}
240
- </p>
241
- {/if}
242
- {#if video.input.stream}
243
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
244
- Video: {video.input.stream.getVideoTracks().length} tracks
245
  </p>
246
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
247
- Audio: {video.input.stream.getAudioTracks().length} tracks
248
  </p>
249
- {/if}
 
 
 
 
 
 
 
 
 
 
250
  </div>
251
- <Button
252
- variant="destructive"
253
- size="sm"
254
- onclick={handleDisconnect}
255
- class="h-7 px-2 text-xs"
256
- >
257
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
258
- Disconnect
259
- </Button>
260
  </div>
261
- </div>
262
- </Card.Content>
263
- </Card.Root>
264
- {/if}
 
 
 
 
 
 
 
265
 
266
- <!-- Local Camera -->
267
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
268
- <Card.Header>
269
- <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
270
- <span class="icon-[mdi--camera] size-4"></span>
271
- Local Camera
272
- </Card.Title>
273
- <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
274
- Use your device camera for direct video capture and recording
275
- </Card.Description>
276
- </Card.Header>
277
- <Card.Content class="space-y-3">
278
- {#if video?.hasInput && video.input.type === 'local-camera'}
279
- <!-- Camera Connected State -->
280
- <div class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20">
281
- <div class="flex items-center justify-between">
282
- <div>
283
- <p class="text-sm font-medium text-blue-700 dark:text-blue-300">Camera Connected</p>
284
- <p class="text-xs text-blue-600/70 dark:text-blue-400/70">Local device camera active</p>
285
- </div>
286
- <Button
287
- variant="destructive"
288
- size="sm"
289
- onclick={handleDisconnect}
290
- disabled={isConnecting}
291
- class="h-7 px-2 text-xs"
292
- >
293
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
294
- {isConnecting ? 'Disconnecting...' : 'Disconnect'}
295
- </Button>
296
- </div>
297
- </div>
298
- {:else}
299
- <!-- Camera Connection Button -->
300
- <Button
301
- variant="secondary"
302
- onclick={handleConnectCamera}
303
- disabled={isConnecting || video?.hasInput}
304
- class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
305
- >
306
- <span class="icon-[mdi--camera] mr-2 size-4"></span>
307
- {isConnecting ? 'Connecting...' : 'Connect to Camera'}
308
- </Button>
309
-
310
- {#if video?.hasInput}
311
- <p class="text-xs text-slate-600 dark:text-slate-500">
312
- Disconnect current input to connect camera
313
- </p>
314
  {/if}
315
- {/if}
316
- </Card.Content>
317
- </Card.Root>
318
 
319
- <!-- Remote Collaboration -->
320
- <Card.Root class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5">
321
- <Card.Header>
322
- <div class="flex items-center justify-between">
323
- <div>
324
- <Card.Title class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200">
325
- <span class="icon-[mdi--cloud-download] size-4"></span>
326
- Remote Collaboration (Rooms)
327
- </Card.Title>
328
- <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
329
- Receive video streams from remote cameras or AI systems
330
- </Card.Description>
331
- </div>
332
- <Button
333
- variant="ghost"
334
- size="sm"
335
- onclick={refreshRooms}
336
- disabled={videoManager.roomsLoading || isConnecting}
337
- class="h-7 px-2 text-xs text-purple-700 hover:text-purple-800 hover:bg-purple-200/20 dark:text-purple-300 dark:hover:text-purple-200 dark:hover:bg-purple-500/20"
338
- >
339
- {#if videoManager.roomsLoading}
340
- <span class="icon-[mdi--loading] animate-spin size-3 mr-1"></span>
341
- Refreshing
342
- {:else}
343
- <span class="icon-[mdi--refresh] size-3 mr-1"></span>
344
- Refresh
345
- {/if}
346
- </Button>
347
- </div>
348
- </Card.Header>
349
- <Card.Content class="space-y-4">
350
- {#if video?.hasInput && video.input.type !== 'local-camera'}
351
- <!-- Remote Connected State -->
352
- <div class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20">
353
- <div class="flex items-center justify-between">
354
- <div>
355
- <p class="text-sm font-medium text-purple-700 dark:text-purple-300">Room Connected</p>
356
- <p class="text-xs text-purple-600/70 dark:text-purple-400/70">Receiving remote video stream</p>
357
- </div>
358
- <Button
359
- variant="destructive"
360
- size="sm"
361
- onclick={handleDisconnect}
362
- disabled={isConnecting}
363
- class="h-7 px-2 text-xs"
364
  >
365
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
366
- {isConnecting ? 'Leaving...' : 'Leave Room'}
367
- </Button>
 
 
 
368
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  </div>
370
- {:else}
371
- <!-- Create New Room -->
372
- <div class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5">
373
- <div class="space-y-2">
374
- <div class="flex items-center gap-2">
375
- <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"></span>
376
- <p class="text-sm font-medium text-green-700 dark:text-green-300">Create New Room</p>
377
- </div>
378
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
379
- Create a room to receive video from others
380
- </p>
381
- <input
382
- bind:value={customRoomId}
383
- placeholder={`Room ID (default: ${video.id})`}
384
- disabled={isConnecting || video?.hasInput}
385
- class="w-full px-2 py-1 bg-slate-50 border border-slate-300 rounded text-xs text-slate-900 disabled:opacity-50 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
386
- />
387
- <div class="flex gap-1">
388
  <Button
389
- variant="secondary"
390
  size="sm"
391
- onclick={createRoom}
392
- disabled={isConnecting || video?.hasInput}
393
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
394
  >
395
- Create Only
 
396
  </Button>
397
- <Button
398
- variant="secondary"
399
- size="sm"
400
- onclick={createRoomAndConnect}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  disabled={isConnecting || video?.hasInput}
402
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
403
- >
404
- Create & Connect
405
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  </div>
407
  </div>
408
- </div>
409
 
410
- <!-- Existing Rooms -->
411
- <div class="space-y-2">
412
- <div class="flex items-center justify-between">
413
- <span class="text-xs font-medium text-purple-700 dark:text-purple-300">Join Existing Room:</span>
414
- <span class="text-xs text-slate-600 dark:text-slate-400">
415
- {videoManager.rooms.length} room{videoManager.rooms.length !== 1 ? 's' : ''} available
416
- </span>
417
- </div>
418
-
419
- <div class="max-h-40 space-y-2 overflow-y-auto">
420
- {#if videoManager.rooms.length === 0}
421
- <div class="text-center py-3 text-xs text-slate-600 dark:text-slate-400">
422
- {videoManager.roomsLoading ? 'Loading rooms...' : 'No rooms available. Create one to get started.'}
423
- </div>
424
- {:else}
425
- {#each videoManager.rooms as room}
426
- <div class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50">
427
- <div class="flex items-start justify-between gap-3">
428
- <div class="flex-1 min-w-0">
429
- <p class="text-xs font-medium text-slate-800 truncate dark:text-slate-200">
430
- {room.id}
431
- </p>
432
- <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
433
- <span>{room.participants?.producer ? '📹 Has Output' : '📭 No Output'}</span>
434
- <span>👥 {room.participants?.consumers?.length || 0} inputs</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  </div>
437
- {#if room.participants?.producer}
438
- <Button
439
- variant="secondary"
440
- size="sm"
441
- onclick={() => handleConnectToRoom(room.id)}
442
- disabled={isConnecting || video?.hasInput}
443
- class="h-6 px-2 text-xs bg-purple-500 hover:bg-purple-600 shrink-0 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
444
- >
445
- <span class="icon-[mdi--download] mr-1 size-3"></span>
446
- Join as Input
447
- </Button>
448
- {:else}
449
- <Button
450
- variant="ghost"
451
- size="sm"
452
- disabled
453
- class="text-xs opacity-50 shrink-0"
454
- >
455
- No Output
456
- </Button>
457
- {/if}
458
  </div>
459
- </div>
460
- {/each}
461
- {/if}
462
  </div>
463
- </div>
464
 
465
- {#if video?.hasInput}
466
- <p class="text-xs text-slate-600 dark:text-slate-500">
467
- Disconnect current input to join a room
468
- </p>
 
469
  {/if}
470
- {/if}
471
- </Card.Content>
472
- </Card.Root>
473
 
474
- <!-- Help Information -->
475
- <Alert.Root class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30">
476
- <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
477
- <Alert.Title class="text-slate-700 dark:text-slate-300">Video Input Sources</Alert.Title>
478
- <Alert.Description class="text-slate-600 text-xs dark:text-slate-400">
479
- <strong>Camera:</strong> Local device camera • <strong>Remote:</strong> Video streams from rooms • Only one active at a time
480
- </Alert.Description>
481
- </Alert.Root>
 
 
 
482
  </div>
483
  </div>
484
  </Dialog.Content>
485
- </Dialog.Root>
 
16
 
17
  let { open = $bindable(), video, workspaceId }: Props = $props();
18
 
19
+ let isConnecting = $state(false);
20
+ let error = $state<string | null>(null);
21
+ let customRoomId = $state("");
22
+ let hasLoadedRooms = $state(false);
23
 
24
+ // Auto-load rooms when modal opens (only once per modal session)
25
+ $effect(() => {
26
+ if (open && !hasLoadedRooms && !videoManager.roomsLoading) {
27
+ refreshRooms();
28
+ hasLoadedRooms = true;
29
+ }
 
 
 
 
 
 
 
30
 
31
+ // Reset when modal closes
32
+ if (!open) {
33
+ hasLoadedRooms = false;
34
+ error = null;
35
+ }
36
+ });
 
 
37
 
38
+ async function refreshRooms() {
39
+ try {
40
+ error = null;
41
+ await videoManager.refreshRooms(workspaceId);
42
+ } catch (err) {
43
+ error = err instanceof Error ? err.message : "Failed to refresh rooms";
44
+ }
45
+ }
46
+
47
+ async function handleConnectCamera() {
48
+ try {
49
+ isConnecting = true;
50
+ error = null;
51
+
52
+ const result = await videoManager.connectLocalCamera(video.id);
53
+ if (result.success) {
54
+ toast.success("Camera Connected", {
55
+ description: "Successfully connected to local camera"
56
+ });
57
+ } else {
58
+ error = result.error || "Failed to connect camera";
59
+ toast.error("Camera Connection Failed", {
60
+ description: error
61
+ });
62
+ }
63
+ } catch (err) {
64
+ error = err instanceof Error ? err.message : "Failed to connect camera";
65
+ toast.error("Camera Connection Error", {
66
+ description: error
67
+ });
68
+ } finally {
69
+ isConnecting = false;
70
+ }
71
+ }
72
+
73
+ async function handleConnectToRoom(roomId: string) {
74
+ try {
75
+ isConnecting = true;
76
+ error = null;
77
+ const result = await videoManager.connectRemoteStream(workspaceId, video.id, roomId);
78
+ if (result.success) {
79
+ toast.success("Connected to Room", {
80
+ description: `Successfully connected to room ${roomId} - receiving video stream`
81
+ });
82
+ } else {
83
+ error = result.error || "Failed to connect to room";
84
+ }
85
+ } catch (err) {
86
+ error = err instanceof Error ? err.message : "Failed to connect to room";
87
+ } finally {
88
+ isConnecting = false;
89
+ }
90
+ }
91
+
92
+ async function createRoom() {
93
+ try {
94
+ isConnecting = true;
95
+ error = null;
96
+ const roomId = customRoomId.trim() || video.id;
97
+ const result = await videoManager.createVideoRoom(workspaceId, roomId);
98
 
99
+ if (result.success) {
100
+ customRoomId = "";
101
+ await refreshRooms();
102
+ toast.success("Room Created", {
103
+ description: `Successfully created room ${result.roomId}`
104
+ });
105
+ } else {
106
+ error = result.error || "Failed to create room";
107
+ }
108
+ } catch (err) {
109
+ error = err instanceof Error ? err.message : "Failed to create room";
110
+ } finally {
111
+ isConnecting = false;
112
+ }
113
+ }
114
+
115
+ async function createRoomAndConnect() {
116
+ try {
117
+ isConnecting = true;
118
+ error = null;
119
+ const roomId = customRoomId.trim() || video.id;
120
+ const createResult = await videoManager.createVideoRoom(workspaceId, roomId);
121
 
122
+ if (!createResult.success) {
123
+ error = createResult.error || "Failed to create room";
124
+ return;
125
+ }
126
+
127
+ const connectResult = await videoManager.connectRemoteStream(
128
+ workspaceId,
129
+ video.id,
130
+ createResult.roomId!
131
+ );
132
+ if (connectResult.success) {
133
+ customRoomId = "";
134
+ await refreshRooms();
135
+ toast.success("Room Created & Connected", {
136
+ description: `Successfully created and connected to room ${createResult.roomId} - receiving video stream`
137
+ });
138
+ } else {
139
+ error = connectResult.error || "Failed to connect to room";
140
+ }
141
+ } catch (err) {
142
+ error = err instanceof Error ? err.message : "Failed to create room and connect";
143
+ } finally {
144
+ isConnecting = false;
145
+ }
146
+ }
147
 
148
+ async function handleDisconnect() {
149
+ try {
150
+ isConnecting = true;
151
+ error = null;
152
+ await videoManager.disconnectVideoInput(video.id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ // Small delay to ensure reactive state updates
155
+ await new Promise((resolve) => setTimeout(resolve, 100));
156
+
157
+ toast.success("Video Input Disconnected", {
158
+ description: "Successfully disconnected video input"
159
+ });
160
+ } catch (err) {
161
+ error = err instanceof Error ? err.message : "Failed to disconnect video input";
162
+ toast.error("Disconnect Failed", {
163
+ description: error
164
+ });
165
+ } finally {
166
+ isConnecting = false;
167
+ }
168
+ }
 
 
 
 
 
 
169
  </script>
170
 
171
  <Dialog.Root bind:open>
 
173
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
174
  >
175
  <Dialog.Header class="pb-3">
176
+ <Dialog.Title
177
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
178
+ >
179
+ <span class="icon-[mdi--video-input-component] size-5 text-green-500 dark:text-green-400"
180
+ ></span>
181
+ Video Input - {video?.name || "No Video Selected"}
182
  </Dialog.Title>
183
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
184
  Configure video input source: local camera for recording or remote streams from rooms
 
189
  <div class="space-y-4 pb-4">
190
  <!-- Error display -->
191
  {#if error}
192
+ <Alert.Root
193
+ class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20"
194
+ >
195
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
196
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
197
+ <Alert.Description class="text-sm text-red-600 dark:text-red-400">
198
  {error}
199
  </Alert.Description>
200
  </Alert.Root>
201
  {/if}
202
+ <!-- Current Status Overview -->
203
+ <Card.Root
204
+ class="border-green-300/30 bg-green-100/20 dark:border-green-500/30 dark:bg-green-900/20"
205
+ >
206
+ <Card.Content class="p-4">
207
+ <div class="flex items-center justify-between">
208
+ <div class="flex items-center gap-2">
209
+ <span
210
+ class="icon-[mdi--video-input-component] size-4 text-green-500 dark:text-green-400"
211
+ ></span>
212
+ <span class="text-sm font-medium text-green-700 dark:text-green-300"
213
+ >Current Video Input</span
214
+ >
215
+ </div>
216
+ {#if video?.hasInput}
217
+ <Badge variant="default" class="bg-green-500 text-xs dark:bg-green-600">
218
+ {video.input.type === "local-camera" ? "Local Camera" : "Remote Stream"}
219
+ </Badge>
 
 
220
  {:else}
221
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
222
+ >No Input Connected</Badge
223
+ >
224
  {/if}
225
  </div>
226
+ {#if video?.hasInput}
227
+ <div class="mt-2 text-xs text-green-600/70 dark:text-green-400/70">
228
+ {#if video.input.roomId}
229
+ Room: {video.input.roomId}
230
+ {:else}
231
+ Source: Local Device Camera
232
+ {/if}
233
+ </div>
234
+ {/if}
235
+ </Card.Content>
236
+ </Card.Root>
237
 
238
+ <!-- Current Input Details -->
239
+ {#if video?.hasInput}
240
+ <Card.Root
241
+ class="border-green-300/30 bg-green-100/5 dark:border-green-500/30 dark:bg-green-500/5"
242
+ >
243
+ <Card.Header>
244
+ <Card.Title
245
+ class="flex items-center gap-2 text-base text-green-700 dark:text-green-200"
246
+ >
247
+ <span class="icon-[mdi--video] size-4"></span>
248
+ Current Input
249
+ </Card.Title>
250
+ </Card.Header>
251
+ <Card.Content>
252
+ <div
253
+ class="rounded-lg border border-green-300/30 bg-green-100/20 p-3 dark:border-green-500/30 dark:bg-green-900/20"
254
+ >
255
+ <div class="flex items-center justify-between">
256
+ <div>
257
+ <p class="text-sm font-medium text-green-700 dark:text-green-300">
258
+ {video.input.type === "local-camera" ? "Local Camera" : "Remote Stream"}
259
+ </p>
260
+ {#if video.input.roomId}
261
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
262
+ Room: {video.input.roomId}
263
+ </p>
264
+ {/if}
265
+ {#if video.input.stream}
266
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
267
+ Video: {video.input.stream.getVideoTracks().length} tracks
268
+ </p>
269
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
270
+ Audio: {video.input.stream.getAudioTracks().length} tracks
271
+ </p>
272
+ {/if}
273
+ </div>
274
+ <Button
275
+ variant="destructive"
276
+ size="sm"
277
+ onclick={handleDisconnect}
278
+ class="h-7 px-2 text-xs"
279
+ >
280
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
281
+ Disconnect
282
+ </Button>
283
+ </div>
284
+ </div>
285
+ </Card.Content>
286
+ </Card.Root>
287
+ {/if}
288
+
289
+ <!-- Local Camera -->
290
+ <Card.Root
291
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
292
+ >
293
  <Card.Header>
294
+ <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
295
+ <span class="icon-[mdi--camera] size-4"></span>
296
+ Local Camera
297
  </Card.Title>
298
+ <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
299
+ Use your device camera for direct video capture and recording
300
+ </Card.Description>
301
  </Card.Header>
302
+ <Card.Content class="space-y-3">
303
+ {#if video?.hasInput && video.input.type === "local-camera"}
304
+ <!-- Camera Connected State -->
305
+ <div
306
+ class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20"
307
+ >
308
+ <div class="flex items-center justify-between">
309
+ <div>
310
+ <p class="text-sm font-medium text-blue-700 dark:text-blue-300">
311
+ Camera Connected
 
 
 
 
 
312
  </p>
313
+ <p class="text-xs text-blue-600/70 dark:text-blue-400/70">
314
+ Local device camera active
315
  </p>
316
+ </div>
317
+ <Button
318
+ variant="destructive"
319
+ size="sm"
320
+ onclick={handleDisconnect}
321
+ disabled={isConnecting}
322
+ class="h-7 px-2 text-xs"
323
+ >
324
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
325
+ {isConnecting ? "Disconnecting..." : "Disconnect"}
326
+ </Button>
327
  </div>
 
 
 
 
 
 
 
 
 
328
  </div>
329
+ {:else}
330
+ <!-- Camera Connection Button -->
331
+ <Button
332
+ variant="secondary"
333
+ onclick={handleConnectCamera}
334
+ disabled={isConnecting || video?.hasInput}
335
+ class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
336
+ >
337
+ <span class="icon-[mdi--camera] mr-2 size-4"></span>
338
+ {isConnecting ? "Connecting..." : "Connect to Camera"}
339
+ </Button>
340
 
341
+ {#if video?.hasInput}
342
+ <p class="text-xs text-slate-600 dark:text-slate-500">
343
+ Disconnect current input to connect camera
344
+ </p>
345
+ {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  {/if}
347
+ </Card.Content>
348
+ </Card.Root>
 
349
 
350
+ <!-- Remote Collaboration -->
351
+ <Card.Root
352
+ class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5"
353
+ >
354
+ <Card.Header>
355
+ <div class="flex items-center justify-between">
356
+ <div>
357
+ <Card.Title
358
+ class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  >
360
+ <span class="icon-[mdi--cloud-download] size-4"></span>
361
+ Remote Collaboration (Rooms)
362
+ </Card.Title>
363
+ <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
364
+ Receive video streams from remote cameras or AI systems
365
+ </Card.Description>
366
  </div>
367
+ <Button
368
+ variant="ghost"
369
+ size="sm"
370
+ onclick={refreshRooms}
371
+ disabled={videoManager.roomsLoading || isConnecting}
372
+ class="h-7 px-2 text-xs text-purple-700 hover:bg-purple-200/20 hover:text-purple-800 dark:text-purple-300 dark:hover:bg-purple-500/20 dark:hover:text-purple-200"
373
+ >
374
+ {#if videoManager.roomsLoading}
375
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
376
+ Refreshing
377
+ {:else}
378
+ <span class="icon-[mdi--refresh] mr-1 size-3"></span>
379
+ Refresh
380
+ {/if}
381
+ </Button>
382
  </div>
383
+ </Card.Header>
384
+ <Card.Content class="space-y-4">
385
+ {#if video?.hasInput && video.input.type !== "local-camera"}
386
+ <!-- Remote Connected State -->
387
+ <div
388
+ class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20"
389
+ >
390
+ <div class="flex items-center justify-between">
391
+ <div>
392
+ <p class="text-sm font-medium text-purple-700 dark:text-purple-300">
393
+ Room Connected
394
+ </p>
395
+ <p class="text-xs text-purple-600/70 dark:text-purple-400/70">
396
+ Receiving remote video stream
397
+ </p>
398
+ </div>
 
 
399
  <Button
400
+ variant="destructive"
401
  size="sm"
402
+ onclick={handleDisconnect}
403
+ disabled={isConnecting}
404
+ class="h-7 px-2 text-xs"
405
  >
406
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
407
+ {isConnecting ? "Leaving..." : "Leave Room"}
408
  </Button>
409
+ </div>
410
+ </div>
411
+ {:else}
412
+ <!-- Create New Room -->
413
+ <div
414
+ class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5"
415
+ >
416
+ <div class="space-y-2">
417
+ <div class="flex items-center gap-2">
418
+ <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"
419
+ ></span>
420
+ <p class="text-sm font-medium text-green-700 dark:text-green-300">
421
+ Create New Room
422
+ </p>
423
+ </div>
424
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
425
+ Create a room to receive video from others
426
+ </p>
427
+ <input
428
+ bind:value={customRoomId}
429
+ placeholder={`Room ID (default: ${video.id})`}
430
  disabled={isConnecting || video?.hasInput}
431
+ class="w-full rounded border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-900 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
432
+ />
433
+ <div class="flex gap-1">
434
+ <Button
435
+ variant="secondary"
436
+ size="sm"
437
+ onclick={createRoom}
438
+ disabled={isConnecting || video?.hasInput}
439
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
440
+ >
441
+ Create Only
442
+ </Button>
443
+ <Button
444
+ variant="secondary"
445
+ size="sm"
446
+ onclick={createRoomAndConnect}
447
+ disabled={isConnecting || video?.hasInput}
448
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
449
+ >
450
+ Create & Connect
451
+ </Button>
452
+ </div>
453
  </div>
454
  </div>
 
455
 
456
+ <!-- Existing Rooms -->
457
+ <div class="space-y-2">
458
+ <div class="flex items-center justify-between">
459
+ <span class="text-xs font-medium text-purple-700 dark:text-purple-300"
460
+ >Join Existing Room:</span
461
+ >
462
+ <span class="text-xs text-slate-600 dark:text-slate-400">
463
+ {videoManager.rooms.length} room{videoManager.rooms.length !== 1 ? "s" : ""} available
464
+ </span>
465
+ </div>
466
+
467
+ <div class="max-h-40 space-y-2 overflow-y-auto">
468
+ {#if videoManager.rooms.length === 0}
469
+ <div class="py-3 text-center text-xs text-slate-600 dark:text-slate-400">
470
+ {videoManager.roomsLoading
471
+ ? "Loading rooms..."
472
+ : "No rooms available. Create one to get started."}
473
+ </div>
474
+ {:else}
475
+ {#each videoManager.rooms as room}
476
+ <div
477
+ class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50"
478
+ >
479
+ <div class="flex items-start justify-between gap-3">
480
+ <div class="min-w-0 flex-1">
481
+ <p
482
+ class="truncate text-xs font-medium text-slate-800 dark:text-slate-200"
483
+ >
484
+ {room.id}
485
+ </p>
486
+ <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
487
+ <span
488
+ >{room.participants?.producer
489
+ ? "📹 Has Output"
490
+ : "📭 No Output"}</span
491
+ >
492
+ <span>👥 {room.participants?.consumers?.length || 0} inputs</span>
493
+ </div>
494
  </div>
495
+ {#if room.participants?.producer}
496
+ <Button
497
+ variant="secondary"
498
+ size="sm"
499
+ onclick={() => handleConnectToRoom(room.id)}
500
+ disabled={isConnecting || video?.hasInput}
501
+ class="h-6 shrink-0 bg-purple-500 px-2 text-xs hover:bg-purple-600 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
502
+ >
503
+ <span class="icon-[mdi--download] mr-1 size-3"></span>
504
+ Join as Input
505
+ </Button>
506
+ {:else}
507
+ <Button
508
+ variant="ghost"
509
+ size="sm"
510
+ disabled
511
+ class="shrink-0 text-xs opacity-50"
512
+ >
513
+ No Output
514
+ </Button>
515
+ {/if}
516
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  </div>
518
+ {/each}
519
+ {/if}
520
+ </div>
521
  </div>
 
522
 
523
+ {#if video?.hasInput}
524
+ <p class="text-xs text-slate-600 dark:text-slate-500">
525
+ Disconnect current input to join a room
526
+ </p>
527
+ {/if}
528
  {/if}
529
+ </Card.Content>
530
+ </Card.Root>
 
531
 
532
+ <!-- Help Information -->
533
+ <Alert.Root
534
+ class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30"
535
+ >
536
+ <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
537
+ <Alert.Title class="text-slate-700 dark:text-slate-300">Video Input Sources</Alert.Title>
538
+ <Alert.Description class="text-xs text-slate-600 dark:text-slate-400">
539
+ <strong>Camera:</strong> Local device camera • <strong>Remote:</strong> Video streams from
540
+ rooms • Only one active at a time
541
+ </Alert.Description>
542
+ </Alert.Root>
543
  </div>
544
  </div>
545
  </Dialog.Content>
546
+ </Dialog.Root>
src/lib/components/3d/elements/video/modal/VideoOutputConnectionModal.svelte CHANGED
@@ -16,112 +16,169 @@
16
 
17
  let { open = $bindable(), video, workspaceId }: Props = $props();
18
 
19
- let isConnecting = $state(false);
20
- let error = $state<string | null>(null);
21
- let customRoomId = $state('');
22
- let hasLoadedRooms = $state(false);
 
 
23
 
24
- // Auto-load rooms when modal opens (only once per modal session)
25
- $effect(() => {
26
- if (open && !hasLoadedRooms && !videoManager.roomsLoading) {
27
- refreshRooms();
28
- hasLoadedRooms = true;
29
- }
30
-
31
- // Reset when modal closes
32
- if (!open) {
33
- hasLoadedRooms = false;
34
- error = null;
35
- }
36
- });
37
 
38
- async function refreshRooms() {
39
- try {
40
- error = null;
41
- await videoManager.refreshRooms(workspaceId);
42
- } catch (err) {
43
- error = err instanceof Error ? err.message : 'Failed to refresh rooms';
44
- }
45
- }
46
 
47
- async function handleStartOutputToRoom(roomId: string) {
48
- try {
49
- isConnecting = true;
50
- error = null;
51
- const result = await videoManager.startVideoOutputToRoom(workspaceId, video.id, roomId);
52
- if (result.success) {
53
- toast.success("Broadcasting Started", {
54
- description: `Successfully started broadcasting to room ${roomId}`
55
- });
56
- } else {
57
- error = result.error || 'Failed to start output to room';
58
- }
59
- } catch (err) {
60
- error = err instanceof Error ? err.message : 'Failed to start output to room';
61
- } finally {
62
- isConnecting = false;
63
- }
64
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- async function createRoom() {
67
- try {
68
- isConnecting = true;
69
- error = null;
70
- const roomId = customRoomId.trim() || video.id;
71
- const result = await videoManager.createVideoRoom(workspaceId, roomId);
72
-
73
- if (result.success) {
74
- customRoomId = '';
75
- await refreshRooms();
76
- toast.success("Room Created", {
77
- description: `Successfully created room ${result.roomId}`
78
- });
79
- } else {
80
- error = result.error || 'Failed to create room';
81
- }
82
- } catch (err) {
83
- error = err instanceof Error ? err.message : 'Failed to create room';
84
- } finally {
85
- isConnecting = false;
86
- }
87
- }
88
 
89
- async function createRoomAndStartOutput() {
90
- try {
91
- isConnecting = true;
92
- error = null;
93
- const roomId = customRoomId.trim() || video.id;
94
- const result = await videoManager.startVideoOutputAsProducer(workspaceId, video.id);
95
- if (result.success) {
96
- customRoomId = '';
97
- await refreshRooms();
98
- toast.success("Room Created & Broadcasting", {
99
- description: `Successfully created room and started broadcasting`
100
- });
101
- } else {
102
- error = result.error || 'Failed to create room and start output';
103
- }
104
- } catch (err) {
105
- error = err instanceof Error ? err.message : 'Failed to create room and start output';
106
- } finally {
107
- isConnecting = false;
108
- }
109
- }
110
 
111
- async function handleStopOutput() {
112
- try {
113
- isConnecting = true;
114
- error = null;
115
- await videoManager.stopVideoOutput(video.id);
116
- toast.success("Broadcasting Stopped", {
117
- description: "Successfully stopped video broadcasting"
118
- });
119
- } catch (err) {
120
- error = err instanceof Error ? err.message : 'Failed to stop broadcasting';
121
- } finally {
122
- isConnecting = false;
123
- }
124
- }
 
 
 
 
 
 
125
  </script>
126
 
127
  <Dialog.Root bind:open>
@@ -129,9 +186,12 @@
129
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
130
  >
131
  <Dialog.Header class="pb-3">
132
- <Dialog.Title class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
133
- <span class="icon-[mdi--video-wireless-outline] size-5 text-orange-500 dark:text-orange-400"></span>
134
- Video Output - {video?.name || 'No Video Selected'}
 
 
 
135
  </Dialog.Title>
136
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
137
  Configure video output: local recording or remote broadcast to rooms
@@ -142,302 +202,356 @@
142
  <div class="space-y-4 pb-4">
143
  <!-- Error display -->
144
  {#if error}
145
- <Alert.Root class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20">
 
 
146
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
147
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
148
- <Alert.Description class="text-red-600 text-sm dark:text-red-400">
149
  {error}
150
  </Alert.Description>
151
  </Alert.Root>
152
  {/if}
153
 
154
- <!-- Current Status Overview -->
155
- <Card.Root class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20">
156
- <Card.Content class="p-4">
157
- <div class="flex items-center justify-between">
158
- <div class="flex items-center gap-2">
159
- <span class="icon-[mdi--video-wireless-outline] size-4 text-orange-500 dark:text-orange-400"></span>
160
- <span class="text-sm font-medium text-orange-700 dark:text-orange-300">Current Video Output</span>
161
- </div>
162
- {#if video?.hasOutput}
163
- <Badge variant="default" class="bg-orange-500 text-xs dark:bg-orange-600">
164
- {video.output.type === 'recording' ? 'Recording' : 'Remote Broadcast'}
165
- </Badge>
166
- {:else}
167
- <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400">No Output Active</Badge>
168
- {/if}
169
- </div>
170
- {#if video?.hasOutput}
171
- <div class="mt-2 text-xs text-orange-600/70 dark:text-orange-400/70">
172
- {#if video.output.roomId}
173
- Broadcasting to Room: {video.output.roomId}
174
  {:else}
175
- Recording to local storage
 
 
176
  {/if}
177
  </div>
178
- {/if}
179
- </Card.Content>
180
- </Card.Root>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- <!-- Current Output Details -->
183
- {#if video?.hasOutput}
184
- <Card.Root class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5">
 
185
  <Card.Header>
186
- <Card.Title class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200">
187
- <span class="icon-[mdi--video-wireless] size-4"></span>
188
- Current Output
189
  </Card.Title>
 
 
 
190
  </Card.Header>
191
- <Card.Content>
192
- <div class="rounded-lg border border-orange-300/30 bg-orange-100/20 p-3 dark:border-orange-500/30 dark:bg-orange-900/20">
193
- <div class="flex items-center justify-between">
194
- <div>
195
- <p class="text-sm font-medium text-orange-700 dark:text-orange-300">
196
- {video.output.type === 'recording' ? 'Local Recording' : 'Remote Broadcast'}
197
- </p>
198
- {#if video.output.roomId}
199
- <p class="text-xs text-orange-600/70 dark:text-orange-400/70">
200
- Room: {video.output.roomId}
201
  </p>
202
- {/if}
203
- {#if video.output.stream}
204
- <p class="text-xs text-orange-600/70 dark:text-orange-400/70">
205
- Status: Active • {video.output.stream.getVideoTracks().length} video tracks
206
  </p>
207
- {/if}
 
 
 
 
 
 
 
 
 
 
208
  </div>
209
- <Button
210
- variant="destructive"
211
- size="sm"
212
- onclick={handleStopOutput}
213
- class="h-7 px-2 text-xs"
214
- >
215
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
216
- Stop
217
- </Button>
218
  </div>
219
- </div>
220
- </Card.Content>
221
- </Card.Root>
222
- {/if}
 
 
 
 
 
 
 
223
 
224
- <!-- Local Recording -->
225
- <Card.Root class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5">
226
- <Card.Header>
227
- <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
228
- <span class="icon-[mdi--record-rec] size-4"></span>
229
- Local Recording
230
- </Card.Title>
231
- <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
232
- Record video directly to your device for later use
233
- </Card.Description>
234
- </Card.Header>
235
- <Card.Content class="space-y-3">
236
- {#if video?.hasOutput && video.output.type === 'recording'}
237
- <!-- Recording Active State -->
238
- <div class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20">
239
- <div class="flex items-center justify-between">
240
- <div>
241
- <p class="text-sm font-medium text-blue-700 dark:text-blue-300">Recording Active</p>
242
- <p class="text-xs text-blue-600/70 dark:text-blue-400/70">Saving to local device</p>
243
- </div>
244
- <Button
245
- variant="destructive"
246
- size="sm"
247
- onclick={handleStopOutput}
248
- disabled={isConnecting}
249
- class="h-7 px-2 text-xs"
250
- >
251
- <span class="icon-[mdi--stop] mr-1 size-3"></span>
252
- {isConnecting ? 'Stopping...' : 'Stop Recording'}
253
- </Button>
254
- </div>
255
- </div>
256
- {:else}
257
- <!-- Recording Start Button -->
258
- <Button
259
- variant="secondary"
260
- onclick={handleStartRecording}
261
- disabled={isConnecting || video?.hasOutput}
262
- class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
263
- >
264
- <span class="icon-[mdi--record] mr-2 size-4"></span>
265
- {isConnecting ? 'Starting...' : 'Start Recording'}
266
- </Button>
267
-
268
- {#if video?.hasOutput}
269
- <p class="text-xs text-slate-600 dark:text-slate-500">
270
- Stop current output to start recording
271
- </p>
272
  {/if}
273
- {/if}
274
- </Card.Content>
275
- </Card.Root>
276
 
277
- <!-- Remote Collaboration -->
278
- <Card.Root class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5">
279
- <Card.Header>
280
- <div class="flex items-center justify-between">
281
- <div>
282
- <Card.Title class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200">
283
- <span class="icon-[mdi--cloud-upload] size-4"></span>
284
- Remote Collaboration (Rooms)
285
- </Card.Title>
286
- <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
287
- Broadcast video stream to remote systems and users
288
- </Card.Description>
289
- </div>
290
- <Button
291
- variant="ghost"
292
- size="sm"
293
- onclick={refreshRooms}
294
- disabled={videoManager.roomsLoading || isConnecting}
295
- class="h-7 px-2 text-xs text-purple-700 hover:text-purple-800 hover:bg-purple-200/20 dark:text-purple-300 dark:hover:text-purple-200 dark:hover:bg-purple-500/20"
296
- >
297
- {#if videoManager.roomsLoading}
298
- <span class="icon-[mdi--loading] animate-spin size-3 mr-1"></span>
299
- Refreshing
300
- {:else}
301
- <span class="icon-[mdi--refresh] size-3 mr-1"></span>
302
- Refresh
303
- {/if}
304
- </Button>
305
- </div>
306
- </Card.Header>
307
- <Card.Content class="space-y-4">
308
- {#if video?.hasOutput && video.output.type !== 'recording'}
309
- <!-- Remote Connected State -->
310
- <div class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20">
311
- <div class="flex items-center justify-between">
312
- <div>
313
- <p class="text-sm font-medium text-purple-700 dark:text-purple-300">Broadcasting to Room</p>
314
- <p class="text-xs text-purple-600/70 dark:text-purple-400/70">Video stream active</p>
315
- </div>
316
- <Button
317
- variant="destructive"
318
- size="sm"
319
- onclick={handleStopOutput}
320
- disabled={isConnecting}
321
- class="h-7 px-2 text-xs"
322
  >
323
- <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
324
- {isConnecting ? 'Stopping...' : 'Stop Broadcast'}
325
- </Button>
 
 
 
326
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  </div>
328
- {:else}
329
- <!-- Create New Room -->
330
- <div class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5">
331
- <div class="space-y-2">
332
- <div class="flex items-center gap-2">
333
- <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"></span>
334
- <p class="text-sm font-medium text-green-700 dark:text-green-300">Create New Room</p>
335
- </div>
336
- <p class="text-xs text-green-600/70 dark:text-green-400/70">
337
- Create a room to broadcast your video
338
- </p>
339
- <input
340
- bind:value={customRoomId}
341
- placeholder={`Room ID (default: ${video.id})`}
342
- disabled={isConnecting || video?.hasOutput}
343
- class="w-full px-2 py-1 bg-slate-50 border border-slate-300 rounded text-xs text-slate-900 disabled:opacity-50 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100"
344
- />
345
- <div class="flex gap-1">
346
  <Button
347
- variant="secondary"
348
  size="sm"
349
- onclick={createRoom}
350
- disabled={isConnecting || video?.hasOutput}
351
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
352
  >
353
- Create Only
 
354
  </Button>
355
- <Button
356
- variant="secondary"
357
- size="sm"
358
- onclick={createRoomAndStartOutput}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  disabled={isConnecting || video?.hasOutput}
360
- class="h-6 px-2 text-xs bg-green-500 hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
361
- >
362
- Create & Broadcast
363
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  </div>
365
  </div>
366
- </div>
367
 
368
- <!-- Existing Rooms -->
369
- <div class="space-y-2">
370
- <div class="flex items-center justify-between">
371
- <span class="text-xs font-medium text-purple-700 dark:text-purple-300">Join Existing Room:</span>
372
- <span class="text-xs text-slate-600 dark:text-slate-400">
373
- {videoManager.rooms.length} room{videoManager.rooms.length !== 1 ? 's' : ''} available
374
- </span>
375
- </div>
376
-
377
- <div class="max-h-40 space-y-2 overflow-y-auto">
378
- {#if videoManager.rooms.length === 0}
379
- <div class="text-center py-3 text-xs text-slate-600 dark:text-slate-400">
380
- {videoManager.roomsLoading ? 'Loading rooms...' : 'No rooms available. Create one to get started.'}
381
- </div>
382
- {:else}
383
- {#each videoManager.rooms as room}
384
- <div class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50">
385
- <div class="flex items-start justify-between gap-3">
386
- <div class="flex-1 min-w-0">
387
- <p class="text-xs font-medium text-slate-800 truncate dark:text-slate-200">
388
- {room.id}
389
- </p>
390
- <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
391
- <span>{room.participants?.producer ? '🔴 Has Output' : '🟢 Available'}</span>
392
- <span>👥 {room.participants?.consumers?.length || 0} inputs</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  </div>
395
- {#if !room.participants?.producer}
396
- <Button
397
- variant="secondary"
398
- size="sm"
399
- onclick={() => handleStartOutputToRoom(room.id)}
400
- disabled={isConnecting || video?.hasOutput}
401
- class="h-6 px-2 text-xs bg-purple-500 hover:bg-purple-600 shrink-0 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
402
- >
403
- <span class="icon-[mdi--upload] mr-1 size-3"></span>
404
- Join as Output
405
- </Button>
406
- {:else}
407
- <Button
408
- variant="ghost"
409
- size="sm"
410
- disabled
411
- class="text-xs opacity-50 shrink-0"
412
- >
413
- Has Output
414
- </Button>
415
- {/if}
416
  </div>
417
- </div>
418
- {/each}
419
- {/if}
420
  </div>
421
- </div>
422
 
423
- {#if video?.hasOutput}
424
- <p class="text-xs text-slate-600 dark:text-slate-500">
425
- Stop current output to join a room
426
- </p>
 
427
  {/if}
428
- {/if}
429
- </Card.Content>
430
- </Card.Root>
431
 
432
- <!-- Help Information -->
433
- <Alert.Root class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30">
434
- <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
435
- <Alert.Title class="text-slate-700 dark:text-slate-300">Video Output Options</Alert.Title>
436
- <Alert.Description class="text-slate-600 text-xs dark:text-slate-400">
437
- <strong>Recording:</strong> Save locally <strong>Remote:</strong> Broadcast to rooms • Only one active at a time
438
- </Alert.Description>
439
- </Alert.Root>
 
 
 
440
  </div>
441
  </div>
442
  </Dialog.Content>
443
- </Dialog.Root>
 
16
 
17
  let { open = $bindable(), video, workspaceId }: Props = $props();
18
 
19
+ let isConnecting = $state(false);
20
+ let error = $state<string | null>(null);
21
+ let customRoomId = $state("");
22
+ let hasLoadedRooms = $state(false);
23
+ let mediaRecorder: MediaRecorder | null = null;
24
+ let recordedChunks: Blob[] = [];
25
 
26
+ // Auto-load rooms when modal opens (only once per modal session)
27
+ $effect(() => {
28
+ if (open && !hasLoadedRooms && !videoManager.roomsLoading) {
29
+ refreshRooms();
30
+ hasLoadedRooms = true;
31
+ }
 
 
 
 
 
 
 
32
 
33
+ // Reset when modal closes
34
+ if (!open) {
35
+ hasLoadedRooms = false;
36
+ error = null;
37
+ }
38
+ });
 
 
39
 
40
+ async function refreshRooms() {
41
+ try {
42
+ error = null;
43
+ await videoManager.refreshRooms(workspaceId);
44
+ } catch (err) {
45
+ error = err instanceof Error ? err.message : "Failed to refresh rooms";
46
+ }
47
+ }
48
+
49
+ async function handleStartOutputToRoom(roomId: string) {
50
+ try {
51
+ isConnecting = true;
52
+ error = null;
53
+ const result = await videoManager.startVideoOutputToRoom(workspaceId, video.id, roomId);
54
+ if (result.success) {
55
+ toast.success("Broadcasting Started", {
56
+ description: `Successfully started broadcasting to room ${roomId}`
57
+ });
58
+ } else {
59
+ error = result.error || "Failed to start output to room";
60
+ }
61
+ } catch (err) {
62
+ error = err instanceof Error ? err.message : "Failed to start output to room";
63
+ } finally {
64
+ isConnecting = false;
65
+ }
66
+ }
67
+
68
+ async function createRoom() {
69
+ try {
70
+ isConnecting = true;
71
+ error = null;
72
+ const roomId = customRoomId.trim() || video.id;
73
+ const result = await videoManager.createVideoRoom(workspaceId, roomId);
74
+
75
+ if (result.success) {
76
+ customRoomId = "";
77
+ await refreshRooms();
78
+ toast.success("Room Created", {
79
+ description: `Successfully created room ${result.roomId}`
80
+ });
81
+ } else {
82
+ error = result.error || "Failed to create room";
83
+ }
84
+ } catch (err) {
85
+ error = err instanceof Error ? err.message : "Failed to create room";
86
+ } finally {
87
+ isConnecting = false;
88
+ }
89
+ }
90
+
91
+ async function createRoomAndStartOutput() {
92
+ try {
93
+ isConnecting = true;
94
+ error = null;
95
+ const roomId = customRoomId.trim() || video.id;
96
+ const result = await videoManager.startVideoOutputAsProducer(workspaceId, video.id);
97
+ if (result.success) {
98
+ customRoomId = "";
99
+ await refreshRooms();
100
+ toast.success("Room Created & Broadcasting", {
101
+ description: `Successfully created room and started broadcasting`
102
+ });
103
+ } else {
104
+ error = result.error || "Failed to create room and start output";
105
+ }
106
+ } catch (err) {
107
+ error = err instanceof Error ? err.message : "Failed to create room and start output";
108
+ } finally {
109
+ isConnecting = false;
110
+ }
111
+ }
112
+
113
+ async function handleStartRecording() {
114
+ try {
115
+ if (!video.canOutput || !video.input.stream) {
116
+ error = "No local camera input available for recording";
117
+ return;
118
+ }
119
+
120
+ isConnecting = true;
121
+ error = null;
122
+
123
+ recordedChunks = [];
124
+ mediaRecorder = new MediaRecorder(video.input.stream, { mimeType: "video/webm; codecs=vp9" });
125
+
126
+ mediaRecorder.ondataavailable = (event) => {
127
+ if (event.data.size > 0) recordedChunks.push(event.data);
128
+ };
129
+
130
+ mediaRecorder.onstop = () => {
131
+ const blob = new Blob(recordedChunks, { type: "video/webm" });
132
+ const url = URL.createObjectURL(blob);
133
+ const a = document.createElement("a");
134
+ a.href = url;
135
+ a.download = `${video.name || video.id}.webm`;
136
+ a.click();
137
+ URL.revokeObjectURL(url);
138
+ };
139
+
140
+ mediaRecorder.start();
141
 
142
+ // Update video output state locally
143
+ video.output.active = true;
144
+ video.output.type = "recording";
145
+ video.output.stream = video.input.stream;
146
+ video.output.roomId = null;
147
+ video.output.client = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
+ toast.success("Recording Started", { description: "Local recording has started" });
150
+ } catch (err) {
151
+ error = err instanceof Error ? err.message : "Failed to start recording";
152
+ } finally {
153
+ isConnecting = false;
154
+ }
155
+ }
156
+
157
+ async function handleStopOutput() {
158
+ try {
159
+ isConnecting = true;
160
+ error = null;
 
 
 
 
 
 
 
 
 
161
 
162
+ if (video.output.type === "recording") {
163
+ if (mediaRecorder && mediaRecorder.state !== "inactive") {
164
+ mediaRecorder.stop();
165
+ }
166
+ video.output.active = false;
167
+ video.output.type = null;
168
+ video.output.stream = null;
169
+ toast.success("Recording Stopped", { description: "Recording saved to file" });
170
+ } else {
171
+ await videoManager.stopVideoOutput(video.id);
172
+ toast.success("Broadcasting Stopped", {
173
+ description: "Successfully stopped video broadcasting"
174
+ });
175
+ }
176
+ } catch (err) {
177
+ error = err instanceof Error ? err.message : "Failed to stop output";
178
+ } finally {
179
+ isConnecting = false;
180
+ }
181
+ }
182
  </script>
183
 
184
  <Dialog.Root bind:open>
 
186
  class="max-h-[85vh] max-w-4xl overflow-hidden border-slate-300 bg-slate-100 text-slate-900 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-100"
187
  >
188
  <Dialog.Header class="pb-3">
189
+ <Dialog.Title
190
+ class="flex items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100"
191
+ >
192
+ <span class="icon-[mdi--video-wireless-outline] size-5 text-orange-500 dark:text-orange-400"
193
+ ></span>
194
+ Video Output - {video?.name || "No Video Selected"}
195
  </Dialog.Title>
196
  <Dialog.Description class="text-sm text-slate-600 dark:text-slate-400">
197
  Configure video output: local recording or remote broadcast to rooms
 
202
  <div class="space-y-4 pb-4">
203
  <!-- Error display -->
204
  {#if error}
205
+ <Alert.Root
206
+ class="border-red-300/30 bg-red-100/20 dark:border-red-500/30 dark:bg-red-900/20"
207
+ >
208
  <span class="icon-[mdi--alert-circle] size-4 text-red-500 dark:text-red-400"></span>
209
  <Alert.Title class="text-red-700 dark:text-red-300">Connection Error</Alert.Title>
210
+ <Alert.Description class="text-sm text-red-600 dark:text-red-400">
211
  {error}
212
  </Alert.Description>
213
  </Alert.Root>
214
  {/if}
215
 
216
+ <!-- Current Status Overview -->
217
+ <Card.Root
218
+ class="border-orange-300/30 bg-orange-100/20 dark:border-orange-500/30 dark:bg-orange-900/20"
219
+ >
220
+ <Card.Content class="p-4">
221
+ <div class="flex items-center justify-between">
222
+ <div class="flex items-center gap-2">
223
+ <span
224
+ class="icon-[mdi--video-wireless-outline] size-4 text-orange-500 dark:text-orange-400"
225
+ ></span>
226
+ <span class="text-sm font-medium text-orange-700 dark:text-orange-300"
227
+ >Current Video Output</span
228
+ >
229
+ </div>
230
+ {#if video?.hasOutput}
231
+ <Badge variant="default" class="bg-orange-500 text-xs dark:bg-orange-600">
232
+ {video.output.type === "recording" ? "Recording" : "Remote Broadcast"}
233
+ </Badge>
 
 
234
  {:else}
235
+ <Badge variant="secondary" class="text-xs text-slate-600 dark:text-slate-400"
236
+ >No Output Active</Badge
237
+ >
238
  {/if}
239
  </div>
240
+ {#if video?.hasOutput}
241
+ <div class="mt-2 text-xs text-orange-600/70 dark:text-orange-400/70">
242
+ {#if video.output.roomId}
243
+ Broadcasting to Room: {video.output.roomId}
244
+ {:else}
245
+ Recording to local storage
246
+ {/if}
247
+ </div>
248
+ {/if}
249
+ </Card.Content>
250
+ </Card.Root>
251
+
252
+ <!-- Current Output Details -->
253
+ {#if video?.hasOutput}
254
+ <Card.Root
255
+ class="border-orange-300/30 bg-orange-100/5 dark:border-orange-500/30 dark:bg-orange-500/5"
256
+ >
257
+ <Card.Header>
258
+ <Card.Title
259
+ class="flex items-center gap-2 text-base text-orange-700 dark:text-orange-200"
260
+ >
261
+ <span class="icon-[mdi--video-wireless] size-4"></span>
262
+ Current Output
263
+ </Card.Title>
264
+ </Card.Header>
265
+ <Card.Content>
266
+ <div
267
+ class="rounded-lg border border-orange-300/30 bg-orange-100/20 p-3 dark:border-orange-500/30 dark:bg-orange-900/20"
268
+ >
269
+ <div class="flex items-center justify-between">
270
+ <div>
271
+ <p class="text-sm font-medium text-orange-700 dark:text-orange-300">
272
+ {video.output.type === "recording" ? "Local Recording" : "Remote Broadcast"}
273
+ </p>
274
+ {#if video.output.roomId}
275
+ <p class="text-xs text-orange-600/70 dark:text-orange-400/70">
276
+ Room: {video.output.roomId}
277
+ </p>
278
+ {/if}
279
+ {#if video.output.stream}
280
+ <p class="text-xs text-orange-600/70 dark:text-orange-400/70">
281
+ Status: Active • {video.output.stream.getVideoTracks().length} video tracks
282
+ </p>
283
+ {/if}
284
+ </div>
285
+ <Button
286
+ variant="destructive"
287
+ size="sm"
288
+ onclick={handleStopOutput}
289
+ class="h-7 px-2 text-xs"
290
+ >
291
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
292
+ Stop
293
+ </Button>
294
+ </div>
295
+ </div>
296
+ </Card.Content>
297
+ </Card.Root>
298
+ {/if}
299
 
300
+ <!-- Local Recording -->
301
+ <Card.Root
302
+ class="border-blue-300/30 bg-blue-100/5 dark:border-blue-500/30 dark:bg-blue-500/5"
303
+ >
304
  <Card.Header>
305
+ <Card.Title class="flex items-center gap-2 text-base text-blue-700 dark:text-blue-200">
306
+ <span class="icon-[mdi--record-rec] size-4"></span>
307
+ Local Recording
308
  </Card.Title>
309
+ <Card.Description class="text-xs text-blue-600/70 dark:text-blue-300/70">
310
+ Record video directly to your device for later use
311
+ </Card.Description>
312
  </Card.Header>
313
+ <Card.Content class="space-y-3">
314
+ {#if video?.hasOutput && video.output.type === "recording"}
315
+ <!-- Recording Active State -->
316
+ <div
317
+ class="rounded-lg border border-blue-300/30 bg-blue-100/20 p-3 dark:border-blue-500/30 dark:bg-blue-900/20"
318
+ >
319
+ <div class="flex items-center justify-between">
320
+ <div>
321
+ <p class="text-sm font-medium text-blue-700 dark:text-blue-300">
322
+ Recording Active
323
  </p>
324
+ <p class="text-xs text-blue-600/70 dark:text-blue-400/70">
325
+ Saving to local device
 
 
326
  </p>
327
+ </div>
328
+ <Button
329
+ variant="destructive"
330
+ size="sm"
331
+ onclick={handleStopOutput}
332
+ disabled={isConnecting}
333
+ class="h-7 px-2 text-xs"
334
+ >
335
+ <span class="icon-[mdi--stop] mr-1 size-3"></span>
336
+ {isConnecting ? "Stopping..." : "Stop Recording"}
337
+ </Button>
338
  </div>
 
 
 
 
 
 
 
 
 
339
  </div>
340
+ {:else}
341
+ <!-- Recording Start Button -->
342
+ <Button
343
+ variant="secondary"
344
+ onclick={handleStartRecording}
345
+ disabled={isConnecting || video?.hasOutput}
346
+ class="w-full bg-blue-500 text-sm text-white hover:bg-blue-600 disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700"
347
+ >
348
+ <span class="icon-[mdi--record] mr-2 size-4"></span>
349
+ {isConnecting ? "Starting..." : "Start Recording"}
350
+ </Button>
351
 
352
+ {#if video?.hasOutput}
353
+ <p class="text-xs text-slate-600 dark:text-slate-500">
354
+ Stop current output to start recording
355
+ </p>
356
+ {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  {/if}
358
+ </Card.Content>
359
+ </Card.Root>
 
360
 
361
+ <!-- Remote Collaboration -->
362
+ <Card.Root
363
+ class="border-purple-300/30 bg-purple-100/5 dark:border-purple-500/30 dark:bg-purple-500/5"
364
+ >
365
+ <Card.Header>
366
+ <div class="flex items-center justify-between">
367
+ <div>
368
+ <Card.Title
369
+ class="flex items-center gap-2 text-base text-purple-700 dark:text-purple-200"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  >
371
+ <span class="icon-[mdi--cloud-upload] size-4"></span>
372
+ Remote Collaboration (Rooms)
373
+ </Card.Title>
374
+ <Card.Description class="text-xs text-purple-600/70 dark:text-purple-300/70">
375
+ Broadcast video stream to remote systems and users
376
+ </Card.Description>
377
  </div>
378
+ <Button
379
+ variant="ghost"
380
+ size="sm"
381
+ onclick={refreshRooms}
382
+ disabled={videoManager.roomsLoading || isConnecting}
383
+ class="h-7 px-2 text-xs text-purple-700 hover:bg-purple-200/20 hover:text-purple-800 dark:text-purple-300 dark:hover:bg-purple-500/20 dark:hover:text-purple-200"
384
+ >
385
+ {#if videoManager.roomsLoading}
386
+ <span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span>
387
+ Refreshing
388
+ {:else}
389
+ <span class="icon-[mdi--refresh] mr-1 size-3"></span>
390
+ Refresh
391
+ {/if}
392
+ </Button>
393
  </div>
394
+ </Card.Header>
395
+ <Card.Content class="space-y-4">
396
+ {#if video?.hasOutput && video.output.type !== "recording"}
397
+ <!-- Remote Connected State -->
398
+ <div
399
+ class="rounded-lg border border-purple-300/30 bg-purple-100/20 p-3 dark:border-purple-500/30 dark:bg-purple-900/20"
400
+ >
401
+ <div class="flex items-center justify-between">
402
+ <div>
403
+ <p class="text-sm font-medium text-purple-700 dark:text-purple-300">
404
+ Broadcasting to Room
405
+ </p>
406
+ <p class="text-xs text-purple-600/70 dark:text-purple-400/70">
407
+ Video stream active
408
+ </p>
409
+ </div>
 
 
410
  <Button
411
+ variant="destructive"
412
  size="sm"
413
+ onclick={handleStopOutput}
414
+ disabled={isConnecting}
415
+ class="h-7 px-2 text-xs"
416
  >
417
+ <span class="icon-[mdi--close-circle] mr-1 size-3"></span>
418
+ {isConnecting ? "Stopping..." : "Stop Broadcast"}
419
  </Button>
420
+ </div>
421
+ </div>
422
+ {:else}
423
+ <!-- Create New Room -->
424
+ <div
425
+ class="rounded border-2 border-dashed border-green-400/50 bg-green-100/5 p-3 dark:border-green-500/50 dark:bg-green-500/5"
426
+ >
427
+ <div class="space-y-2">
428
+ <div class="flex items-center gap-2">
429
+ <span class="icon-[mdi--plus-circle] size-4 text-green-500 dark:text-green-400"
430
+ ></span>
431
+ <p class="text-sm font-medium text-green-700 dark:text-green-300">
432
+ Create New Room
433
+ </p>
434
+ </div>
435
+ <p class="text-xs text-green-600/70 dark:text-green-400/70">
436
+ Create a room to broadcast your video
437
+ </p>
438
+ <input
439
+ bind:value={customRoomId}
440
+ placeholder={`Room ID (default: ${video.id})`}
441
  disabled={isConnecting || video?.hasOutput}
442
+ class="w-full rounded border border-slate-300 bg-slate-50 px-2 py-1 text-xs text-slate-900 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
443
+ />
444
+ <div class="flex gap-1">
445
+ <Button
446
+ variant="secondary"
447
+ size="sm"
448
+ onclick={createRoom}
449
+ disabled={isConnecting || video?.hasOutput}
450
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
451
+ >
452
+ Create Only
453
+ </Button>
454
+ <Button
455
+ variant="secondary"
456
+ size="sm"
457
+ onclick={createRoomAndStartOutput}
458
+ disabled={isConnecting || video?.hasOutput}
459
+ class="h-6 bg-green-500 px-2 text-xs hover:bg-green-600 disabled:opacity-50 dark:bg-green-600 dark:hover:bg-green-700"
460
+ >
461
+ Create & Broadcast
462
+ </Button>
463
+ </div>
464
  </div>
465
  </div>
 
466
 
467
+ <!-- Existing Rooms -->
468
+ <div class="space-y-2">
469
+ <div class="flex items-center justify-between">
470
+ <span class="text-xs font-medium text-purple-700 dark:text-purple-300"
471
+ >Join Existing Room:</span
472
+ >
473
+ <span class="text-xs text-slate-600 dark:text-slate-400">
474
+ {videoManager.rooms.length} room{videoManager.rooms.length !== 1 ? "s" : ""} available
475
+ </span>
476
+ </div>
477
+
478
+ <div class="max-h-40 space-y-2 overflow-y-auto">
479
+ {#if videoManager.rooms.length === 0}
480
+ <div class="py-3 text-center text-xs text-slate-600 dark:text-slate-400">
481
+ {videoManager.roomsLoading
482
+ ? "Loading rooms..."
483
+ : "No rooms available. Create one to get started."}
484
+ </div>
485
+ {:else}
486
+ {#each videoManager.rooms as room}
487
+ <div
488
+ class="rounded border border-slate-300 bg-slate-50/50 p-2 dark:border-slate-600 dark:bg-slate-800/50"
489
+ >
490
+ <div class="flex items-start justify-between gap-3">
491
+ <div class="min-w-0 flex-1">
492
+ <p
493
+ class="truncate text-xs font-medium text-slate-800 dark:text-slate-200"
494
+ >
495
+ {room.id}
496
+ </p>
497
+ <div class="flex gap-3 text-xs text-slate-600 dark:text-slate-400">
498
+ <span
499
+ >{room.participants?.producer
500
+ ? "🔴 Has Output"
501
+ : "🟢 Available"}</span
502
+ >
503
+ <span>👥 {room.participants?.consumers?.length || 0} inputs</span>
504
+ </div>
505
  </div>
506
+ {#if !room.participants?.producer}
507
+ <Button
508
+ variant="secondary"
509
+ size="sm"
510
+ onclick={() => handleStartOutputToRoom(room.id)}
511
+ disabled={isConnecting || video?.hasOutput}
512
+ class="h-6 shrink-0 bg-purple-500 px-2 text-xs hover:bg-purple-600 disabled:opacity-50 dark:bg-purple-600 dark:hover:bg-purple-700"
513
+ >
514
+ <span class="icon-[mdi--upload] mr-1 size-3"></span>
515
+ Join as Output
516
+ </Button>
517
+ {:else}
518
+ <Button
519
+ variant="ghost"
520
+ size="sm"
521
+ disabled
522
+ class="shrink-0 text-xs opacity-50"
523
+ >
524
+ Has Output
525
+ </Button>
526
+ {/if}
527
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  </div>
529
+ {/each}
530
+ {/if}
531
+ </div>
532
  </div>
 
533
 
534
+ {#if video?.hasOutput}
535
+ <p class="text-xs text-slate-600 dark:text-slate-500">
536
+ Stop current output to join a room
537
+ </p>
538
+ {/if}
539
  {/if}
540
+ </Card.Content>
541
+ </Card.Root>
 
542
 
543
+ <!-- Help Information -->
544
+ <Alert.Root
545
+ class="border-slate-300 bg-slate-100/30 dark:border-slate-700 dark:bg-slate-800/30"
546
+ >
547
+ <span class="icon-[mdi--help-circle] size-4 text-slate-600 dark:text-slate-400"></span>
548
+ <Alert.Title class="text-slate-700 dark:text-slate-300">Video Output Options</Alert.Title>
549
+ <Alert.Description class="text-xs text-slate-600 dark:text-slate-400">
550
+ <strong>Recording:</strong> Save locally • <strong>Remote:</strong> Broadcast to rooms •
551
+ Only one active at a time
552
+ </Alert.Description>
553
+ </Alert.Root>
554
  </div>
555
  </div>
556
  </Dialog.Content>
557
+ </Dialog.Root>
src/lib/components/3d/elements/video/status/InputVideoBoxUIKit.svelte CHANGED
@@ -1,7 +1,13 @@
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
4
- import { BaseStatusBox, StatusHeader, StatusContent, StatusIndicator, StatusButton } from "$lib/components/3d/ui";
 
 
 
 
 
 
5
 
6
  interface Props {
7
  video: VideoInstance;
@@ -10,18 +16,10 @@
10
 
11
  let { video, handleClick }: Props = $props();
12
 
13
- // Input theme color (green)
14
  const inputColor = "rgb(34, 197, 94)";
15
-
16
  </script>
17
 
18
- <!--
19
- @component
20
- Input connection box showing the status of the input connection.
21
- Displays input information when connected or connection prompt when disconnected.
22
- -->
23
-
24
- <BaseStatusBox
25
  color={inputColor}
26
  borderOpacity={0.6}
27
  backgroundOpacity={0.2}
@@ -30,24 +28,24 @@ Displays input information when connected or connection prompt when disconnected
30
  >
31
  {#if video.hasInput}
32
  <!-- Active Input State -->
33
- {#if video.input.type === 'local-camera'}
34
- <StatusHeader
35
- icon={ICON["icon-[material-symbols--download]"].svg}
36
- text="CAMERA"
37
  color={inputColor}
38
  opacity={0.9}
39
  />
40
  {:else}
41
- <StatusHeader
42
- icon={ICON["icon-[material-symbols--download]"].svg}
43
- text="REMOTE"
44
  color="rgb(96, 165, 250)"
45
  opacity={0.9}
46
  />
47
  {/if}
48
 
49
- <StatusContent
50
- title={video.input.type === 'local-camera' ? 'Local Camera' : `Room: ${video.input.roomId}`}
51
  subtitle="Connected"
52
  color={inputColor}
53
  variant="primary"
@@ -57,20 +55,16 @@ Displays input information when connected or connection prompt when disconnected
57
  <StatusIndicator color={inputColor} />
58
  {:else}
59
  <!-- No Input State -->
60
- <StatusHeader
61
- icon={ICON["icon-[material-symbols--download]"].svg}
62
- text="NO INPUT"
63
  color={inputColor}
64
  opacity={0.7}
65
  />
66
 
67
- <StatusContent
68
- title="Click to Start"
69
- color={inputColor}
70
- variant="secondary"
71
- />
72
 
73
- <StatusButton
74
  icon={ICON["icon-[mdi--plus]"].svg}
75
  text="Add Input"
76
  color={inputColor}
@@ -78,4 +72,4 @@ Displays input information when connected or connection prompt when disconnected
78
  textOpacity={0.7}
79
  />
80
  {/if}
81
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
+ StatusIndicator,
9
+ StatusButton
10
+ } from "$lib/components/3d/ui";
11
 
12
  interface Props {
13
  video: VideoInstance;
 
16
 
17
  let { video, handleClick }: Props = $props();
18
 
 
19
  const inputColor = "rgb(34, 197, 94)";
 
20
  </script>
21
 
22
+ <BaseStatusBox
 
 
 
 
 
 
23
  color={inputColor}
24
  borderOpacity={0.6}
25
  backgroundOpacity={0.2}
 
28
  >
29
  {#if video.hasInput}
30
  <!-- Active Input State -->
31
+ {#if video.input.type === "local-camera"}
32
+ <StatusHeader
33
+ icon={ICON["icon-[material-symbols--download]"].svg}
34
+ text="CAMERA"
35
  color={inputColor}
36
  opacity={0.9}
37
  />
38
  {:else}
39
+ <StatusHeader
40
+ icon={ICON["icon-[material-symbols--download]"].svg}
41
+ text="REMOTE"
42
  color="rgb(96, 165, 250)"
43
  opacity={0.9}
44
  />
45
  {/if}
46
 
47
+ <StatusContent
48
+ title={video.input.type === "local-camera" ? "Local Camera" : `Room: ${video.input.roomId}`}
49
  subtitle="Connected"
50
  color={inputColor}
51
  variant="primary"
 
55
  <StatusIndicator color={inputColor} />
56
  {:else}
57
  <!-- No Input State -->
58
+ <StatusHeader
59
+ icon={ICON["icon-[material-symbols--download]"].svg}
60
+ text="NO INPUT"
61
  color={inputColor}
62
  opacity={0.7}
63
  />
64
 
65
+ <StatusContent title="Click to Start" color={inputColor} variant="secondary" />
 
 
 
 
66
 
67
+ <StatusButton
68
  icon={ICON["icon-[mdi--plus]"].svg}
69
  text="Add Input"
70
  color={inputColor}
 
72
  textOpacity={0.7}
73
  />
74
  {/if}
75
+ </BaseStatusBox>
src/lib/components/3d/elements/video/status/OutputVideoBoxUIKit.svelte CHANGED
@@ -1,7 +1,13 @@
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
4
- import { BaseStatusBox, StatusHeader, StatusContent, StatusIndicator, StatusButton } from "$lib/components/3d/ui";
 
 
 
 
 
 
5
 
6
  interface Props {
7
  video: VideoInstance;
@@ -10,22 +16,10 @@
10
 
11
  let { video, handleClick }: Props = $props();
12
 
13
- // Output theme color (blue)
14
  const outputColor = "rgb(59, 130, 246)";
15
-
16
- // Icons
17
- // const broadcastIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTIgMTBjLTEuMSAwLTIgLjktMiAycy45IDIgMiAyczItLjkgMi0ycy0uOS0yLTItMm02IDJjMC0zLjMtMi43LTYtNi02cy02IDIuNy02IDZjMCAyLjIgMS4yIDQuMSAzIDUuMmwxLTEuN2MtMS4yLS43LTItMi0yLTMuNGMwLTIuMiAxLjgtNCA0LTRzNCAxLjggNCA0YzAgMS41LS44IDIuOC0yIDMuNGwxIDEuN2MxLjgtMSAzLTMgMy01LjJNMTIgMkM2LjUgMiAyIDYuNSAyIDEyYzAgMy43IDIgNi45IDUgOC42bDEtMS43Yy0yLjQtMS40LTQtNC00LTYuOWMwLTQuNCAzLjYtOCA4LThzOCAzLjYgOCA4YzAgMy0xLjYgNS41LTQgNi45bDEgMS43YzMtMS43IDUtNC45IDUtOC42YzAtNS41LTQuNS0xMC0xMC0xMCIvPjwvc3ZnPg==";
18
- // const hdmiPortIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMjEgN0gzYy0xLjEgMC0yIC45LTIgMnY1YzAgMS4xLjkgMiAyIDJoMWwxLjQgMS40Yy40LjQuOS42IDEuNC42aDEwLjNjLjUgMCAxLS4yIDEuNC0uNkwyMCAxNmgxYzEuMSAwIDItLjkgMi0yVjljMC0xLjEtLjktMi0yLTJNMyAxNFY5aDE4djVoLTEuOGwtMiAySDYuOGwtMi0yem0xNi0zSDV2MmgxNHoiLz48L3N2Zz4=";
19
- // const plusIcon = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTkgMTNoLTZ2NmgtMnYtNkg1di0yaDZWNWgydjZoNnoiLz48L3N2Zz4=";
20
  </script>
21
 
22
- <!--
23
- @component
24
- Output connection box showing the status of output connections.
25
- Displays output information when connected or connection prompt when disconnected.
26
- -->
27
-
28
- <BaseStatusBox
29
  color={outputColor}
30
  borderOpacity={0.6}
31
  backgroundOpacity={0.2}
@@ -34,14 +28,14 @@ Displays output information when connected or connection prompt when disconnecte
34
  >
35
  {#if video.hasOutput}
36
  <!-- Active Output State -->
37
- <StatusHeader
38
- icon={ICON["icon-[material-symbols--upload]"].svg}
39
- text="OUTPUT"
40
  color={outputColor}
41
  opacity={0.9}
42
  />
43
 
44
- <StatusContent
45
  title="Broadcasting"
46
  subtitle={`Room: ${video.output.roomId}`}
47
  color={outputColor}
@@ -52,24 +46,24 @@ Displays output information when connected or connection prompt when disconnecte
52
  <StatusIndicator color={outputColor} />
53
  {:else}
54
  <!-- No Output State -->
55
- <StatusHeader
56
- icon={ICON["icon-[material-symbols--upload]"].svg}
57
- text="NO OUTPUT"
58
  color={outputColor}
59
  opacity={0.7}
60
  />
61
 
62
- <StatusContent
63
- title={video.canOutput ? 'Click to Start' : 'Need Camera'}
64
  color={outputColor}
65
  variant="secondary"
66
  />
67
 
68
- <StatusButton
69
  icon={ICON["icon-[mdi--plus]"].svg}
70
  text="Add Output"
71
  color={outputColor}
72
  textOpacity={0.7}
73
  />
74
  {/if}
75
- </BaseStatusBox>
 
1
  <script lang="ts">
2
  import { ICON } from "$lib/utils/icon";
3
  import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte";
4
+ import {
5
+ BaseStatusBox,
6
+ StatusHeader,
7
+ StatusContent,
8
+ StatusIndicator,
9
+ StatusButton
10
+ } from "$lib/components/3d/ui";
11
 
12
  interface Props {
13
  video: VideoInstance;
 
16
 
17
  let { video, handleClick }: Props = $props();
18
 
 
19
  const outputColor = "rgb(59, 130, 246)";
 
 
 
 
 
20
  </script>
21
 
22
+ <BaseStatusBox
 
 
 
 
 
 
23
  color={outputColor}
24
  borderOpacity={0.6}
25
  backgroundOpacity={0.2}
 
28
  >
29
  {#if video.hasOutput}
30
  <!-- Active Output State -->
31
+ <StatusHeader
32
+ icon={ICON["icon-[material-symbols--upload]"].svg}
33
+ text="OUTPUT"
34
  color={outputColor}
35
  opacity={0.9}
36
  />
37
 
38
+ <StatusContent
39
  title="Broadcasting"
40
  subtitle={`Room: ${video.output.roomId}`}
41
  color={outputColor}
 
46
  <StatusIndicator color={outputColor} />
47
  {:else}
48
  <!-- No Output State -->
49
+ <StatusHeader
50
+ icon={ICON["icon-[material-symbols--upload]"].svg}
51
+ text="NO OUTPUT"
52
  color={outputColor}
53
  opacity={0.7}
54
  />
55
 
56
+ <StatusContent
57
+ title={video.canOutput ? "Click to Start" : "Need Camera"}
58
  color={outputColor}
59
  variant="secondary"
60
  />
61
 
62
+ <StatusButton
63
  icon={ICON["icon-[mdi--plus]"].svg}
64
  text="Add Output"
65
  color={outputColor}
66
  textOpacity={0.7}
67
  />
68
  {/if}
69
+ </BaseStatusBox>
src/lib/components/3d/elements/video/status/VideoBoxUIKit.svelte CHANGED
@@ -6,7 +6,6 @@
6
  StatusHeader,
7
  StatusContent
8
  } from "$lib/components/3d/ui";
9
- import { Text } from "threlte-uikit";
10
 
11
  interface Props {
12
  video: VideoInstance;
@@ -14,16 +13,9 @@
14
 
15
  let { video }: Props = $props();
16
 
17
- // Video theme color (orange)
18
  const videoColor = "rgb(217, 119, 6)";
19
-
20
  </script>
21
 
22
- <!--
23
- @component
24
- Video box showing basic video instance information and status.
25
- Displays video name and ID with consistent theming.
26
- -->
27
 
28
  <BaseStatusBox
29
  color={videoColor}
 
6
  StatusHeader,
7
  StatusContent
8
  } from "$lib/components/3d/ui";
 
9
 
10
  interface Props {
11
  video: VideoInstance;
 
13
 
14
  let { video }: Props = $props();
15
 
 
16
  const videoColor = "rgb(217, 119, 6)";
 
17
  </script>
18
 
 
 
 
 
 
19
 
20
  <BaseStatusBox
21
  color={videoColor}
src/lib/components/3d/elements/video/status/VideoConnectionFlowBoxUIKit.svelte CHANGED
@@ -14,7 +14,6 @@
14
 
15
  let { video, onInputBoxClick, onOutputBoxClick }: Props = $props();
16
 
17
- // Colors
18
  const inputColor = "rgb(34, 197, 94)";
19
  const outputColor = "rgb(59, 130, 246)";
20
  </script>
 
14
 
15
  let { video, onInputBoxClick, onOutputBoxClick }: Props = $props();
16
 
 
17
  const inputColor = "rgb(34, 197, 94)";
18
  const outputColor = "rgb(59, 130, 246)";
19
  </script>
src/lib/components/3d/misc/Pointcloud.svelte CHANGED
@@ -284,10 +284,6 @@
284
 
285
  rgbdData = createRGBDData();
286
  });
287
-
288
- $effect(() => {
289
- console.log("render");
290
- });
291
  </script>
292
 
293
  {#if pointCloudGeometry}
 
284
 
285
  rgbdData = createRGBDData();
286
  });
 
 
 
 
287
  </script>
288
 
289
  {#if pointCloudGeometry}
src/lib/components/3d/ui/BaseStatusBox.svelte CHANGED
@@ -10,7 +10,7 @@
10
  backgroundOpacity?: number;
11
  disabled?: boolean;
12
  clickable?: boolean;
13
- children: import('svelte').Snippet;
14
  onclick?: () => void;
15
  }
16
 
@@ -51,15 +51,11 @@
51
  }
52
 
53
  let currentBorderOpacity = $derived(isHovered ? Math.min(borderOpacity * 1.5, 1) : borderOpacity);
54
- let currentBackgroundOpacity = $derived(isHovered ? Math.min(backgroundOpacity * 2, 0.5) : backgroundOpacity);
 
 
55
  </script>
56
 
57
- <!--
58
- @component
59
- Base status box component with hover effects and common styling.
60
- Uses a single color with opacity variations for simplicity.
61
- -->
62
-
63
  <Container
64
  {minWidth}
65
  {minHeight}
@@ -88,4 +84,4 @@ Uses a single color with opacity variations for simplicity.
88
  >
89
  {@render children()}
90
  </Container>
91
- </Container>
 
10
  backgroundOpacity?: number;
11
  disabled?: boolean;
12
  clickable?: boolean;
13
+ children: import("svelte").Snippet;
14
  onclick?: () => void;
15
  }
16
 
 
51
  }
52
 
53
  let currentBorderOpacity = $derived(isHovered ? Math.min(borderOpacity * 1.5, 1) : borderOpacity);
54
+ let currentBackgroundOpacity = $derived(
55
+ isHovered ? Math.min(backgroundOpacity * 2, 0.5) : backgroundOpacity
56
+ );
57
  </script>
58
 
 
 
 
 
 
 
59
  <Container
60
  {minWidth}
61
  {minHeight}
 
84
  >
85
  {@render children()}
86
  </Container>
87
+ </Container>
src/lib/components/3d/ui/StatusArrow.svelte CHANGED
@@ -21,12 +21,6 @@
21
  }: Props = $props();
22
  </script>
23
 
24
- <!--
25
- @component
26
- Reusable arrow component for showing connection flows.
27
- Supports different directions and styling.
28
- -->
29
-
30
  <Container
31
  flexDirection="row"
32
  alignItems="center"
 
21
  }: Props = $props();
22
  </script>
23
 
 
 
 
 
 
 
24
  <Container
25
  flexDirection="row"
26
  alignItems="center"
src/lib/components/3d/ui/StatusButton.svelte CHANGED
@@ -32,12 +32,6 @@
32
  }: Props = $props();
33
  </script>
34
 
35
- <!--
36
- @component
37
- Status button component for action buttons with optional icons.
38
- Uses single color with opacity variations for consistent styling.
39
- -->
40
-
41
  <Container
42
  backgroundColor={color}
43
  {backgroundOpacity}
 
32
  }: Props = $props();
33
  </script>
34
 
 
 
 
 
 
 
35
  <Container
36
  backgroundColor={color}
37
  {backgroundOpacity}
src/lib/components/3d/ui/StatusContent.svelte CHANGED
@@ -40,17 +40,9 @@
40
  const config = sizeConfigs[size];
41
  const opacities = opacityLevels[variant];
42
 
43
- // Convert align to flexbox properties
44
  const flexAlign = align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center';
45
  </script>
46
 
47
- <!--
48
- @component
49
- Simplified status content component with predefined styling levels.
50
- Uses consistent opacity and sizing patterns across all components.
51
- Fixed text centering by properly handling flexbox alignment.
52
- -->
53
-
54
  <Container
55
  padding={config.padding}
56
  marginBottom={4}
 
40
  const config = sizeConfigs[size];
41
  const opacities = opacityLevels[variant];
42
 
 
43
  const flexAlign = align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center';
44
  </script>
45
 
 
 
 
 
 
 
 
46
  <Container
47
  padding={config.padding}
48
  marginBottom={4}
src/lib/components/3d/ui/StatusHeader.svelte CHANGED
@@ -24,20 +24,8 @@
24
  }: Props = $props();
25
  </script>
26
 
27
- <!--
28
- @component
29
- Status header component with icon and text.
30
- Used for consistent headers across all status boxes.
31
- -->
32
-
33
  <Container flexDirection="row" alignItems="center" gap={6} {marginBottom}>
34
- <SVG
35
- width={iconSize}
36
- height={iconSize}
37
- {color}
38
- {opacity}
39
- src={icon}
40
- />
41
  <Text
42
  {text}
43
  {fontSize}
@@ -47,4 +35,4 @@ Used for consistent headers across all status boxes.
47
  textTransform="uppercase"
48
  {letterSpacing}
49
  />
50
- </Container>
 
24
  }: Props = $props();
25
  </script>
26
 
 
 
 
 
 
 
27
  <Container flexDirection="row" alignItems="center" gap={6} {marginBottom}>
28
+ <SVG width={iconSize} height={iconSize} {color} {opacity} src={icon} />
 
 
 
 
 
 
29
  <Text
30
  {text}
31
  {fontSize}
 
35
  textTransform="uppercase"
36
  {letterSpacing}
37
  />
38
+ </Container>
src/lib/components/3d/ui/StatusIndicator.svelte CHANGED
@@ -4,23 +4,13 @@
4
  interface Props {
5
  color?: string;
6
  size?: number;
7
- type?: 'dot' | 'pulse';
8
  visible?: boolean;
9
  }
10
 
11
- let {
12
- color = "rgb(139, 69, 219)",
13
- size = 8,
14
- type = 'dot',
15
- visible = true
16
- }: Props = $props();
17
  </script>
18
 
19
  {#if visible}
20
- <Container
21
- width={size}
22
- height={size}
23
- borderRadius="50%"
24
- backgroundColor={color}
25
- />
26
- {/if}
 
4
  interface Props {
5
  color?: string;
6
  size?: number;
7
+ type?: "dot" | "pulse";
8
  visible?: boolean;
9
  }
10
 
11
+ let { color = "rgb(139, 69, 219)", size = 8, type = "dot", visible = true }: Props = $props();
 
 
 
 
 
12
  </script>
13
 
14
  {#if visible}
15
+ <Container width={size} height={size} borderRadius="50%" backgroundColor={color} />
16
+ {/if}
 
 
 
 
 
src/lib/components/3d/utils/Hoverable.old.svelte DELETED
@@ -1,148 +0,0 @@
1
- <script lang="ts">
2
- import { T } from "@threlte/core";
3
- import type { IntersectionEvent } from "@threlte/extras";
4
- import { interactivity } from "@threlte/extras";
5
- import type { Snippet } from "svelte";
6
- import { Spring, Tween } from "svelte/motion";
7
- import { useCursor } from "@threlte/extras";
8
- import { onMount, onDestroy } from "svelte";
9
- import { Group, Box3, Vector3 } from "three";
10
- import type { Robot } from "$lib/elements/robot/Robot.svelte";
11
-
12
- interface Props {
13
- content: Snippet<[{ isHovered: boolean; isSelected: boolean; offset: number }]>; // renderable
14
- onClickObject?: () => void;
15
- robot?: Robot; // Optional robot for height calculation
16
- rescale?: boolean;
17
- }
18
-
19
- let { content, onClickObject, robot, rescale = true }: Props = $props();
20
-
21
- const scale = new Spring(1);
22
-
23
- // Height calculation state
24
- let groupRef = $state<Group | undefined>(undefined);
25
- let updateInterval: number;
26
- let lastJointSnapshot: string = "";
27
- let lastCalculatedHeight: number = 0;
28
- const CONSTANT_OFFSET = 0.03;
29
- const HEIGHT_THRESHOLD = 0.015; // Only update if height changes by more than 1.5cm
30
- const HEIGHT_QUANTIZATION = 0.01; // Quantize to 1cm steps
31
-
32
- // Hover state
33
- let isHovered = $state(false);
34
- let isSelected = $state(false);
35
- let isHighlighted = $derived(isHovered || isSelected);
36
- let offsetTween = new Tween(0.26, {
37
- duration: 500,
38
- easing: (t) => t * (2 - t)
39
- });
40
-
41
- $effect(() => {
42
- if (isHighlighted) {
43
- if (rescale) {
44
- scale.target = 1.05;
45
- }
46
- } else {
47
- if (rescale) {
48
- scale.target = 1;
49
- }
50
- }
51
- });
52
-
53
- function getJointSnapshot(): string {
54
- if (!robot?.urdfRobotState.urdfRobot.joints) return "";
55
- return robot.urdfRobotState.urdfRobot.robot.joints
56
- .filter((joint) => joint.type === "revolute" || joint.type === "continuous")
57
- .map((joint) => {
58
- const rotation = joint.rotation || [0, 0, 0];
59
- return `${joint.name}:${rotation.map((r) => Math.round(r * 100) / 100).join(",")}`;
60
- })
61
- .join("|");
62
- }
63
-
64
- function quantizeHeight(height: number): number {
65
- return Math.round(height / HEIGHT_QUANTIZATION) * HEIGHT_QUANTIZATION;
66
- }
67
-
68
- function calculateRobotHeight() {
69
- if (!groupRef || !robot) return;
70
-
71
- const currentJointSnapshot = getJointSnapshot();
72
- if (currentJointSnapshot === lastJointSnapshot) {
73
- return; // No significant joint changes
74
- }
75
-
76
- try {
77
- groupRef.updateMatrixWorld(true);
78
- const box = new Box3().setFromObject(groupRef);
79
- const size = new Vector3();
80
- box.getSize(size);
81
- const height = size.y;
82
- const actualHeight = height / (10 * scale.current);
83
- const quantizedHeight = quantizeHeight(actualHeight);
84
- const heightDiff = Math.abs(quantizedHeight - lastCalculatedHeight);
85
- if (heightDiff < HEIGHT_THRESHOLD) {
86
- return; // Change too small, ignore
87
- }
88
-
89
- const newTarget = Math.max(quantizedHeight + CONSTANT_OFFSET, 0.08);
90
- offsetTween.target = newTarget;
91
- lastCalculatedHeight = quantizedHeight;
92
- lastJointSnapshot = currentJointSnapshot;
93
- } catch (error) {
94
- console.warn("Error calculating robot height:", error);
95
- offsetTween.target = 0.28;
96
- }
97
- }
98
-
99
- const handleKeyDown = (event: KeyboardEvent) => {
100
- if (event.key === "Escape" && isSelected) {
101
- isSelected = false;
102
- }
103
- };
104
-
105
- onMount(() => {
106
- if (!robot) return;
107
- // Only run the height‐update check every 200 ms
108
- // updateInterval = setInterval(calculateRobotHeight, 500);
109
- // setTimeout(calculateRobotHeight, 100);
110
-
111
- document.addEventListener("keydown", handleKeyDown);
112
- });
113
-
114
- onDestroy(() => {
115
- // if (updateInterval) {
116
- // clearInterval(updateInterval);
117
- // }
118
-
119
- document.removeEventListener("keydown", handleKeyDown);
120
- });
121
-
122
- const { onPointerEnter, onPointerLeave } = useCursor();
123
- interactivity();
124
- </script>
125
-
126
- <T.Group
127
- bind:ref={groupRef}
128
- onpointerdown={(event: IntersectionEvent<MouseEvent>) => {
129
- event.stopPropagation();
130
- isSelected = true;
131
- onClickObject?.();
132
- }}
133
- onpointerenter={(event: IntersectionEvent<PointerEvent>) => {
134
- event.stopPropagation();
135
- onPointerEnter();
136
- isHovered = true;
137
- }}
138
- onpointerleave={(event: IntersectionEvent<PointerEvent>) => {
139
- event.stopPropagation();
140
- onPointerLeave();
141
- isHovered = false;
142
- }}
143
- scale={scale.current}
144
- >
145
- {#snippet children({ ref })}
146
- {@render content({ isHovered, isSelected, offset: offsetTween.current })}
147
- {/snippet}
148
- </T.Group>