Spaces:
Running
Running
Update
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- README.md +238 -208
- docker-build.sh +0 -30
- log.txt +0 -1
- src/lib/components/3d/Floor.svelte +10 -16
- src/lib/components/3d/elements/compute/ComputeGridItem.svelte +4 -8
- src/lib/components/3d/elements/compute/GPU.svelte +1 -21
- src/lib/components/3d/elements/compute/modal/AISessionConnectionModal.svelte +116 -62
- src/lib/components/3d/elements/compute/modal/RobotInputConnectionModal.svelte +80 -51
- src/lib/components/3d/elements/compute/modal/RobotOutputConnectionModal.svelte +82 -52
- src/lib/components/3d/elements/compute/modal/VideoInputConnectionModal.svelte +38 -36
- src/lib/components/3d/elements/compute/status/ComputeBoxUIKit.svelte +19 -23
- src/lib/components/3d/elements/compute/status/ComputeConnectionFlowBoxUIKit.svelte +4 -13
- src/lib/components/3d/elements/compute/status/ComputeInputBoxUIKit.svelte +6 -17
- src/lib/components/3d/elements/compute/status/ComputeOutputBoxUIKit.svelte +8 -20
- src/lib/components/3d/elements/compute/status/ComputeStatusBillboard.svelte +6 -8
- src/lib/components/3d/elements/compute/status/RobotInputBoxUIKit.svelte +20 -31
- src/lib/components/3d/elements/compute/status/RobotOutputBoxUIKit.svelte +0 -7
- src/lib/components/3d/elements/compute/status/VideoInputBoxUIKit.svelte +1 -15
- src/lib/components/3d/elements/robot/RobotGridItem.svelte +30 -29
- src/lib/components/3d/elements/robot/Robots.svelte +16 -13
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfJoint.svelte +0 -2
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfLink.svelte +0 -6
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfThree.svelte +1 -11
- src/lib/components/3d/elements/robot/URDF/primitives/UrdfVisual.svelte +0 -4
- src/lib/components/3d/elements/robot/URDF/utils/UrdfParser.ts +2 -2
- src/lib/components/3d/elements/robot/modal/InputConnectionModal.svelte +331 -278
- src/lib/components/3d/elements/robot/modal/ManualControlSheet.svelte +56 -28
- src/lib/components/3d/elements/robot/modal/OutputConnectionModal.svelte +283 -233
- src/lib/components/3d/elements/robot/status/ConnectionFlowBoxUIkit.svelte +1 -12
- src/lib/components/3d/elements/robot/status/InputBoxUIKit.svelte +11 -25
- src/lib/components/3d/elements/robot/status/ManualControlBoxUIKit.svelte +17 -22
- src/lib/components/3d/elements/robot/status/OutputBoxUIKit.svelte +14 -23
- src/lib/components/3d/elements/robot/status/RobotBoxUIKit.svelte +0 -1
- src/lib/components/3d/elements/video/Video.svelte +38 -23
- src/lib/components/3d/elements/video/VideoGridItem.svelte +2 -8
- src/lib/components/3d/elements/video/Videos.svelte +3 -3
- src/lib/components/3d/elements/video/modal/VideoInputConnectionModal.svelte +463 -402
- src/lib/components/3d/elements/video/modal/VideoOutputConnectionModal.svelte +474 -360
- src/lib/components/3d/elements/video/status/InputVideoBoxUIKit.svelte +23 -29
- src/lib/components/3d/elements/video/status/OutputVideoBoxUIKit.svelte +19 -25
- src/lib/components/3d/elements/video/status/VideoBoxUIKit.svelte +0 -8
- src/lib/components/3d/elements/video/status/VideoConnectionFlowBoxUIKit.svelte +0 -1
- src/lib/components/3d/misc/Pointcloud.svelte +0 -4
- src/lib/components/3d/ui/BaseStatusBox.svelte +5 -9
- src/lib/components/3d/ui/StatusArrow.svelte +0 -6
- src/lib/components/3d/ui/StatusButton.svelte +0 -6
- src/lib/components/3d/ui/StatusContent.svelte +0 -8
- src/lib/components/3d/ui/StatusHeader.svelte +2 -14
- src/lib/components/3d/ui/StatusIndicator.svelte +4 -14
- src/lib/components/3d/utils/Hoverable.old.svelte +0 -148
README.md
CHANGED
@@ -1,297 +1,327 @@
|
|
1 |
---
|
2 |
-
title:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
12 |
-
|
13 |
-
- robotics
|
14 |
-
- control
|
15 |
-
- simulation
|
16 |
-
- svelte
|
17 |
-
- static
|
18 |
-
- frontend
|
19 |
---
|
20 |
|
21 |
-
# 🤖
|
22 |
|
23 |
-
|
24 |
|
25 |
-
|
26 |
|
27 |
-
|
|
|
|
|
|
|
28 |
|
29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
|
31 |
-
|
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 |
-
|
38 |
-
```yaml
|
39 |
-
sdk: static
|
40 |
-
app_build_command: bun install && bun run build
|
41 |
-
app_file: build/index.html
|
42 |
-
```
|
43 |
|
44 |
-
**
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
[](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 |
-
|
83 |
|
84 |
-
|
85 |
-
```bash
|
86 |
-
docker build -t lerobot-arena-frontend .
|
87 |
-
docker run -p 3000:3000 lerobot-arena-frontend
|
88 |
-
```
|
89 |
|
90 |
-
|
|
|
|
|
|
|
|
|
|
|
91 |
|
92 |
-
|
93 |
|
94 |
-
|
95 |
|
96 |
-
|
97 |
|
98 |
```bash
|
99 |
-
#
|
100 |
-
|
|
|
101 |
|
102 |
-
#
|
103 |
-
bun
|
104 |
|
105 |
-
#
|
106 |
-
bun run dev -- --open
|
107 |
```
|
108 |
|
109 |
-
###
|
110 |
|
111 |
```bash
|
112 |
-
#
|
113 |
-
cd
|
114 |
-
|
115 |
-
# Install Python dependencies (using uv)
|
116 |
-
uv sync
|
117 |
|
118 |
-
#
|
119 |
-
|
|
|
120 |
|
121 |
-
#
|
122 |
-
|
123 |
```
|
124 |
|
125 |
-
|
126 |
|
127 |
-
|
128 |
|
129 |
-
|
130 |
-
# Navigate to Python backend
|
131 |
-
cd src-python
|
132 |
|
133 |
-
|
134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
135 |
|
136 |
-
|
137 |
-
box package
|
138 |
|
139 |
-
|
140 |
-
./target/release/lerobot-arena-server
|
141 |
-
```
|
142 |
|
143 |
-
|
144 |
|
145 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
165 |
|
166 |
-
|
167 |
|
168 |
-
|
169 |
-
|
170 |
-
|
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 |
-
|
176 |
|
177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
178 |
|
179 |
-
|
180 |
|
181 |
-
|
182 |
-
bun run build
|
183 |
-
```
|
184 |
|
185 |
-
|
186 |
|
187 |
```bash
|
188 |
-
|
189 |
-
|
190 |
```
|
191 |
|
192 |
-
|
193 |
|
194 |
-
|
195 |
-
docker-compose up --build
|
196 |
-
```
|
197 |
|
198 |
-
##
|
199 |
|
200 |
-
|
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 |
-
|
|
|
208 |
|
209 |
-
|
210 |
|
211 |
-
|
212 |
|
213 |
-
|
214 |
-
# Check what's using the ports
|
215 |
-
lsof -i :8080
|
216 |
-
lsof -i :3000
|
217 |
|
218 |
-
|
219 |
-
|
220 |
-
|
|
|
221 |
|
222 |
-
|
223 |
|
224 |
-
|
225 |
-
# View logs
|
226 |
-
docker-compose logs lerobot-arena
|
227 |
|
228 |
-
|
229 |
-
docker-compose build --no-cache
|
230 |
-
docker-compose up
|
231 |
-
```
|
232 |
|
233 |
-
|
234 |
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
|
|
|
|
|
|
|
|
|
|
239 |
|
240 |
-
|
241 |
-
|
242 |
-
bun run dev
|
243 |
```
|
244 |
|
245 |
-
|
|
|
|
|
|
|
246 |
|
247 |
-
|
248 |
-
# Clean build artifacts
|
249 |
-
cd src-python
|
250 |
-
box clean
|
251 |
|
252 |
-
|
253 |
-
|
|
|
254 |
|
255 |
-
|
256 |
-
|
|
|
|
|
|
|
|
|
|
|
257 |
```
|
258 |
|
259 |
-
|
260 |
|
261 |
-
|
|
|
|
|
|
|
262 |
|
263 |
-
|
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 |
-
**
|
270 |
-
|
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 |
-
|
276 |
|
277 |
-
|
278 |
|
279 |
-
|
280 |
-
|
281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
282 |
|
283 |
-
|
284 |
|
285 |
-
|
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 |
-
|
|
|
|
|
|
|
|
|
292 |
|
293 |
-
|
294 |
|
295 |
---
|
296 |
|
297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
4 |
-
import { Grid } from
|
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 ===
|
27 |
-
cellColor={mode.current ===
|
28 |
-
selectionColor={mode.current ===
|
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 =
|
|
|
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 {
|
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(
|
26 |
-
let cameraNames = $state(
|
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(
|
41 |
return;
|
42 |
}
|
43 |
|
44 |
isConnecting = true;
|
45 |
try {
|
46 |
-
const cameras = cameraNames
|
|
|
|
|
|
|
47 |
if (cameras.length === 0) {
|
48 |
-
cameras.push(
|
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(
|
68 |
-
toast.error(
|
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(
|
82 |
} else {
|
83 |
toast.error(`Failed to start session: ${result.error}`);
|
84 |
}
|
85 |
} catch (error) {
|
86 |
-
console.error(
|
87 |
-
toast.error(
|
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(
|
101 |
} else {
|
102 |
toast.error(`Failed to stop session: ${result.error}`);
|
103 |
}
|
104 |
} catch (error) {
|
105 |
-
console.error(
|
106 |
-
toast.error(
|
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(
|
120 |
} else {
|
121 |
toast.error(`Failed to delete session: ${result.error}`);
|
122 |
}
|
123 |
} catch (error) {
|
124 |
-
console.error(
|
125 |
-
toast.error(
|
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
|
|
|
|
|
138 |
<span class="icon-[mdi--robot-outline] size-5 text-purple-500 dark:text-purple-400"></span>
|
139 |
-
AI Compute Session - {compute.name ||
|
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"
|
|
|
|
|
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"
|
|
|
|
|
161 |
{/if}
|
162 |
</div>
|
163 |
|
164 |
<!-- Current Session Details -->
|
165 |
{#if compute.hasSession && compute.sessionData}
|
166 |
-
<Card.Root
|
|
|
|
|
167 |
<Card.Header>
|
168 |
-
<Card.Title
|
|
|
|
|
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
|
|
|
|
|
176 |
<div class="grid grid-cols-2 gap-2 text-xs">
|
177 |
<div>
|
178 |
-
<span class="text-purple-700
|
179 |
-
|
|
|
|
|
|
|
180 |
</div>
|
181 |
<div>
|
182 |
-
<span class="text-purple-700
|
183 |
-
<span class="text-purple-800
|
|
|
|
|
184 |
</div>
|
185 |
<div>
|
186 |
-
<span class="text-purple-700
|
187 |
-
<span class="text-purple-800
|
|
|
|
|
188 |
</div>
|
189 |
<div>
|
190 |
-
<span class="text-purple-700
|
191 |
-
<span class="text-purple-800
|
|
|
|
|
192 |
</div>
|
193 |
</div>
|
194 |
</div>
|
195 |
|
196 |
<!-- Connection Details -->
|
197 |
-
<div
|
198 |
-
|
|
|
|
|
|
|
|
|
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="
|
|
|
|
|
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="
|
|
|
208 |
</div>
|
209 |
{/each}
|
210 |
<div>
|
211 |
<span class="text-green-600 dark:text-green-400">📥 Joint Input:</span>
|
212 |
-
<span class="
|
|
|
|
|
213 |
</div>
|
214 |
<div>
|
215 |
<span class="text-green-600 dark:text-green-400">📤 Joint Output:</span>
|
216 |
-
<span class="
|
|
|
|
|
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
|
230 |
>
|
231 |
{#if isConnecting}
|
232 |
-
<span class="icon-[mdi--loading]
|
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]
|
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]
|
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
|
|
|
|
|
281 |
<Card.Header>
|
282 |
-
<Card.Title
|
|
|
|
|
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"
|
|
|
|
|
292 |
<Input
|
293 |
id="sessionId"
|
294 |
bind:value={sessionId}
|
295 |
placeholder="my-session-01"
|
296 |
-
class="
|
297 |
/>
|
298 |
</div>
|
299 |
<div class="space-y-2">
|
300 |
-
<Label for="policyPath" class="text-purple-700 dark:text-purple-300"
|
|
|
|
|
301 |
<Input
|
302 |
id="policyPath"
|
303 |
bind:value={policyPath}
|
304 |
placeholder="./checkpoints/act_so101_beyond"
|
305 |
-
class="
|
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"
|
|
|
|
|
313 |
<Input
|
314 |
id="cameraNames"
|
315 |
bind:value={cameraNames}
|
316 |
placeholder="front, wrist, overhead"
|
317 |
-
class="
|
318 |
/>
|
319 |
-
<p class="text-xs text-slate-600 dark:text-slate-400">
|
|
|
|
|
320 |
</div>
|
321 |
<div class="space-y-2">
|
322 |
-
<Label for="transportServerUrl" class="text-purple-700 dark:text-purple-300"
|
|
|
|
|
323 |
<Input
|
324 |
id="transportServerUrl"
|
325 |
value={settings.transportServerUrl}
|
326 |
disabled
|
327 |
placeholder="http://localhost:8000"
|
328 |
-
class="
|
329 |
title="Change this value in the settings panel"
|
330 |
/>
|
331 |
-
<p class="text-xs text-slate-600 dark:text-slate-400">
|
|
|
|
|
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
|
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
|
351 |
-
joint inputs, and joint outputs in the inference server communication
|
|
|
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]
|
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
|
|
|
|
|
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
|
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(
|
29 |
return;
|
30 |
}
|
31 |
|
32 |
if (!selectedRobotId) {
|
33 |
-
toast.error(
|
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(
|
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(
|
57 |
description: `Robot ${selectedRobotId} now sends joint data to AI`
|
58 |
});
|
59 |
-
|
60 |
} catch (error) {
|
61 |
-
console.error(
|
62 |
-
toast.error(
|
63 |
-
description: error instanceof Error ? error.message :
|
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(
|
85 |
} catch (error) {
|
86 |
-
console.error(
|
87 |
-
toast.error(
|
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
|
|
|
|
|
105 |
<span class="icon-[mdi--robot-industrial] size-5 text-amber-500 dark:text-amber-400"></span>
|
106 |
-
Robot Input - {compute.name ||
|
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"
|
|
|
|
|
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 |
{/if}
|
129 |
</div>
|
130 |
|
131 |
{#if !compute.hasSession}
|
132 |
-
<Card.Root
|
|
|
|
|
133 |
<Card.Header>
|
134 |
-
<Card.Title
|
|
|
|
|
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 |
-
|
142 |
</Card.Content>
|
143 |
</Card.Root>
|
144 |
{:else}
|
145 |
<!-- Robot Selection and Connection -->
|
146 |
-
<Card.Root
|
|
|
|
|
147 |
<Card.Header>
|
148 |
-
<Card.Title
|
|
|
|
|
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">
|
157 |
-
|
|
|
|
|
158 |
{#if robots.length === 0}
|
159 |
-
<div class="
|
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
|
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
|
|
|
|
|
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 ?
|
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
|
216 |
>
|
217 |
{#if isConnecting}
|
218 |
-
<span class="icon-[mdi--loading]
|
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
|
|
|
|
|
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
|
253 |
-
|
254 |
-
|
|
|
|
|
|
|
|
|
255 |
</div>
|
256 |
-
<div class="text-slate-600
|
257 |
-
The robot will act as a <strong>PRODUCER</strong> and send its current joint positions
|
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
|
|
|
|
|
268 |
<Card.Header>
|
269 |
-
<Card.Title
|
|
|
|
|
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
|
277 |
-
The AI model will use this data along with camera
|
|
|
278 |
</div>
|
279 |
</Card.Content>
|
280 |
</Card.Root>
|
@@ -282,10 +308,13 @@
|
|
282 |
{/if}
|
283 |
|
284 |
<!-- Quick Info -->
|
285 |
-
<div
|
|
|
|
|
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
|
|
|
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(
|
29 |
return;
|
30 |
}
|
31 |
|
32 |
if (!selectedRobotId) {
|
33 |
-
toast.error(
|
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(
|
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(
|
57 |
description: `Robot ${selectedRobotId} now receives AI commands`
|
58 |
});
|
59 |
-
|
60 |
} catch (error) {
|
61 |
-
console.error(
|
62 |
-
toast.error(
|
63 |
-
description: error instanceof Error ? error.message :
|
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(
|
82 |
} catch (error) {
|
83 |
-
console.error(
|
84 |
-
toast.error(
|
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
|
|
|
|
|
102 |
<span class="icon-[mdi--robot-outline] size-5 text-blue-500 dark:text-blue-400"></span>
|
103 |
-
Robot Output - {compute.name ||
|
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"
|
|
|
|
|
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"
|
|
|
|
|
125 |
{/if}
|
126 |
</div>
|
127 |
|
128 |
{#if !compute.hasSession}
|
129 |
-
<Card.Root
|
|
|
|
|
130 |
<Card.Header>
|
131 |
-
<Card.Title
|
|
|
|
|
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 |
-
|
139 |
</Card.Content>
|
140 |
</Card.Root>
|
141 |
{:else}
|
142 |
<!-- Robot Selection and Connection -->
|
143 |
-
<Card.Root
|
|
|
|
|
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">
|
154 |
-
|
|
|
|
|
155 |
{#if robots.length === 0}
|
156 |
-
<div class="
|
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
|
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 ?
|
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
|
|
|
|
|
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 ?
|
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
|
213 |
>
|
214 |
{#if isConnecting}
|
215 |
-
<span class="icon-[mdi--loading]
|
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
|
|
|
|
|
241 |
<Card.Header>
|
242 |
-
<Card.Title
|
|
|
|
|
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
|
250 |
-
|
251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
252 |
</div>
|
253 |
-
<div class="text-slate-600
|
254 |
-
The inference server will act as a <strong>PRODUCER</strong> and send predicted joint
|
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
|
|
|
|
|
265 |
<Card.Header>
|
266 |
-
<Card.Title
|
|
|
|
|
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
|
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
|
|
|
|
|
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
|
|
|
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
|
4 |
-
import type { video as videoTypes } from
|
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(
|
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(
|
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(
|
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(
|
74 |
-
toast.error(
|
75 |
-
description: error instanceof Error ? error.message :
|
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(
|
96 |
} catch (error) {
|
97 |
-
console.error(
|
98 |
-
toast.error(
|
99 |
}
|
100 |
}
|
101 |
|
102 |
// Cleanup on modal close
|
103 |
$effect(() => {
|
104 |
return () => {
|
105 |
-
|
106 |
-
|
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 ||
|
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 |
-
|
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
|
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 ?
|
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
|
205 |
>
|
206 |
{#if isConnecting}
|
207 |
-
<span class="icon-[mdi--loading]
|
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
|
|
|
|
|
233 |
<video
|
234 |
autoplay
|
235 |
muted
|
236 |
playsinline
|
237 |
-
class="
|
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
|
261 |
-
<span class="text-blue-300
|
262 |
-
<span class="text-blue-200
|
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
|
|
|
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 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
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 =
|
|
|
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 ?
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
12 |
-
import type IUrdfRobot from
|
13 |
-
import { ROBOT_CONFIG } from
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
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 =
|
54 |
-
|
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 |
-
|
162 |
-
{
|
163 |
-
|
164 |
-
|
165 |
-
|
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(
|
47 |
}
|
48 |
}
|
49 |
|
@@ -54,12 +54,15 @@
|
|
54 |
|
55 |
onDestroy(() => {
|
56 |
// Clean up robots and unlock servos for safety
|
57 |
-
console.log(
|
58 |
-
robotManager
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
|
|
|
|
|
|
63 |
});
|
64 |
</script>
|
65 |
|
@@ -67,9 +70,9 @@
|
|
67 |
<RobotGridItem
|
68 |
{robot}
|
69 |
onCameraMove={() => {}}
|
70 |
-
|
71 |
-
|
72 |
-
|
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:
|
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 :
|
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 ||
|
77 |
}
|
78 |
} catch (err) {
|
79 |
-
error = err instanceof Error ? err.message :
|
80 |
} finally {
|
81 |
isConnecting = false;
|
82 |
}
|
@@ -84,10 +82,10 @@
|
|
84 |
|
85 |
async function joinRoomAsInput() {
|
86 |
if (!selectedRoomId) {
|
87 |
-
error =
|
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 :
|
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 ||
|
121 |
}
|
122 |
} catch (err) {
|
123 |
-
error = err instanceof Error ? err.message :
|
124 |
} finally {
|
125 |
isConnecting = false;
|
126 |
}
|
@@ -132,21 +130,21 @@
|
|
132 |
error = null;
|
133 |
|
134 |
if (robot.calibrationManager.needsCalibration) {
|
135 |
-
pendingUSBConnection =
|
136 |
showUSBCalibration = true;
|
137 |
return;
|
138 |
}
|
139 |
|
140 |
await robot.setConsumer({
|
141 |
-
type:
|
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 :
|
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 :
|
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 ===
|
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
|
200 |
-
|
|
|
|
|
|
|
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
|
|
|
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
|
|
|
|
|
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
|
216 |
{error}
|
217 |
</Alert.Description>
|
218 |
</Alert.Root>
|
219 |
{/if}
|
220 |
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
>
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
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 |
-
<
|
297 |
-
|
|
|
|
|
|
|
298 |
</div>
|
299 |
-
|
300 |
-
variant="
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
class="
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
</Button>
|
309 |
</div>
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
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-
|
342 |
-
|
343 |
</Card.Description>
|
344 |
-
</
|
345 |
-
<
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
355 |
{:else}
|
356 |
-
|
357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
358 |
{/if}
|
359 |
-
</
|
360 |
-
</
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
|
|
366 |
<div class="flex items-center justify-between">
|
367 |
<div>
|
368 |
-
<
|
369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
370 |
</div>
|
371 |
<Button
|
372 |
-
variant="
|
373 |
size="sm"
|
374 |
-
onclick={
|
375 |
-
disabled={isConnecting}
|
376 |
-
class="h-7 px-2 text-xs"
|
377 |
>
|
378 |
-
|
379 |
-
|
|
|
|
|
|
|
|
|
|
|
380 |
</Button>
|
381 |
</div>
|
382 |
-
</
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
390 |
</div>
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
419 |
</div>
|
420 |
-
</div>
|
421 |
-
</div>
|
422 |
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
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 |
-
|
438 |
-
|
439 |
-
|
440 |
-
<div class="
|
441 |
-
|
442 |
-
|
443 |
-
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
448 |
</div>
|
449 |
</div>
|
450 |
-
|
451 |
-
|
452 |
-
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
|
458 |
-
|
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 |
-
|
468 |
-
</
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
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
|
23 |
>
|
24 |
<!-- Header -->
|
25 |
-
<Sheet.Header
|
|
|
|
|
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"
|
31 |
-
|
|
|
|
|
|
|
|
|
32 |
</div>
|
33 |
</div>
|
34 |
</div>
|
@@ -36,41 +42,54 @@
|
|
36 |
|
37 |
{#if robot}
|
38 |
<!-- Content -->
|
39 |
-
<div
|
|
|
|
|
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"
|
46 |
-
|
|
|
|
|
|
|
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
|
|
|
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">
|
|
|
|
|
58 |
{:else}
|
59 |
<div class="space-y-3">
|
60 |
{#each robot.jointArray as joint (joint.name)}
|
61 |
-
{@const isGripper =
|
|
|
62 |
{@const minValue = isGripper ? 0 : -100}
|
63 |
{@const maxValue = isGripper ? 100 : 100}
|
64 |
-
|
65 |
-
<div
|
|
|
|
|
66 |
<div class="flex items-center justify-between">
|
67 |
-
<span class="text-sm font-medium text-slate-800 dark:text-slate-200"
|
|
|
|
|
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
|
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
|
93 |
-
|
94 |
-
|
|
|
|
|
95 |
</div>
|
96 |
</div>
|
97 |
</div>
|
@@ -101,17 +122,24 @@
|
|
101 |
</div>
|
102 |
{:else}
|
103 |
<div class="space-y-4">
|
104 |
-
|
105 |
-
|
106 |
-
|
|
|
|
|
107 |
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
|
|
|
|
|
|
|
|
|
|
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:
|
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 :
|
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 ||
|
85 |
}
|
86 |
} catch (err) {
|
87 |
-
error = err instanceof Error ? err.message :
|
88 |
} finally {
|
89 |
isConnecting = false;
|
90 |
}
|
@@ -92,10 +92,10 @@
|
|
92 |
|
93 |
async function joinRoomAsOutput() {
|
94 |
if (!selectedRoomId) {
|
95 |
-
error =
|
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 :
|
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 ||
|
128 |
}
|
129 |
} catch (err) {
|
130 |
-
error = err instanceof Error ? err.message :
|
131 |
} finally {
|
132 |
isConnecting = false;
|
133 |
}
|
@@ -140,21 +140,21 @@
|
|
140 |
|
141 |
// Check if calibration is needed
|
142 |
if (robot.calibrationManager.needsCalibration) {
|
143 |
-
pendingUSBConnection =
|
144 |
showUSBCalibration = true;
|
145 |
return;
|
146 |
}
|
147 |
|
148 |
await robot.addProducer({
|
149 |
-
type:
|
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 :
|
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 :
|
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 ===
|
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
|
|
|
|
|
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
|
|
|
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
|
|
|
|
|
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
|
225 |
{error}
|
226 |
</Alert.Description>
|
227 |
</Alert.Root>
|
@@ -229,9 +234,11 @@
|
|
229 |
|
230 |
<!-- USB Calibration Panel -->
|
231 |
{#if showUSBCalibration}
|
232 |
-
<Card.Root
|
|
|
|
|
233 |
<Card.Header>
|
234 |
-
<div class="flex justify-between
|
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
|
248 |
-
|
249 |
-
|
250 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
265 |
-
|
266 |
-
|
267 |
-
<
|
268 |
-
<div class="flex items-center
|
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--
|
339 |
-
|
340 |
-
|
341 |
-
|
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 |
-
</
|
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 |
-
|
434 |
-
|
435 |
-
|
|
|
436 |
<Card.Header>
|
437 |
-
<Card.Title
|
438 |
-
|
439 |
-
|
|
|
|
|
440 |
</Card.Title>
|
|
|
|
|
|
|
441 |
</Card.Header>
|
442 |
-
<Card.Content>
|
443 |
-
<
|
444 |
-
|
445 |
-
|
446 |
-
|
447 |
-
|
448 |
-
|
449 |
-
|
450 |
-
|
451 |
-
|
452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
453 |
<Button
|
454 |
-
variant="
|
455 |
size="sm"
|
456 |
-
onclick={
|
457 |
disabled={isConnecting}
|
458 |
-
class="h-6 px-2 text-xs"
|
459 |
>
|
460 |
-
|
461 |
</Button>
|
462 |
</div>
|
463 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
464 |
</div>
|
465 |
</Card.Content>
|
466 |
</Card.Root>
|
467 |
-
{/if}
|
468 |
|
469 |
-
|
470 |
-
|
471 |
-
|
472 |
-
|
473 |
-
|
474 |
-
|
475 |
-
|
476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
51 |
-
title={robot.consumer?.name.slice(0, 30) ?? 'No Input'}
|
52 |
-
color={inputColor}
|
53 |
-
/>
|
54 |
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
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 |
-
|
43 |
-
|
44 |
-
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
86 |
-
const ctx = canvas.getContext(
|
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 =
|
98 |
-
ctx.textAlign =
|
99 |
-
ctx.textBaseline =
|
100 |
-
ctx.font =
|
101 |
-
|
102 |
// Add text shadow for better readability
|
103 |
-
ctx.shadowColor =
|
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 =
|
114 |
-
ctx.shadowColor =
|
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 =
|
|
|
|
|
|
|
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 =
|
|
|
|
|
|
|
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 |
-
|
38 |
-
|
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 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
// Reset when modal closes
|
32 |
-
if (!open) {
|
33 |
-
hasLoadedRooms = false;
|
34 |
-
error = null;
|
35 |
-
}
|
36 |
-
});
|
37 |
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
}
|
45 |
-
}
|
46 |
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
|
|
|
|
|
|
|
|
91 |
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
|
|
|
|
|
|
114 |
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
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 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
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
|
173 |
-
|
174 |
-
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
189 |
{error}
|
190 |
</Alert.Description>
|
191 |
</Alert.Root>
|
192 |
{/if}
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
<
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
{#if video.input.roomId}
|
212 |
-
Room: {video.input.roomId}
|
213 |
{:else}
|
214 |
-
|
|
|
|
|
215 |
{/if}
|
216 |
</div>
|
217 |
-
|
218 |
-
|
219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
220 |
|
221 |
-
|
222 |
-
|
223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
224 |
<Card.Header>
|
225 |
-
<Card.Title class="flex items-center gap-2 text-base text-
|
226 |
-
<span class="icon-[mdi--
|
227 |
-
|
228 |
</Card.Title>
|
|
|
|
|
|
|
229 |
</Card.Header>
|
230 |
-
<Card.Content>
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
<p class="text-
|
239 |
-
|
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-
|
247 |
-
|
248 |
</p>
|
249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
265 |
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
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 |
-
|
316 |
-
</Card.
|
317 |
-
</Card.Root>
|
318 |
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
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--
|
366 |
-
|
367 |
-
</
|
|
|
|
|
|
|
368 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
369 |
</div>
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
/>
|
387 |
-
<div class="flex gap-1">
|
388 |
<Button
|
389 |
-
variant="
|
390 |
size="sm"
|
391 |
-
onclick={
|
392 |
-
disabled={isConnecting
|
393 |
-
class="h-
|
394 |
>
|
395 |
-
|
|
|
396 |
</Button>
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
401 |
disabled={isConnecting || video?.hasInput}
|
402 |
-
class="
|
403 |
-
|
404 |
-
|
405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
406 |
</div>
|
407 |
</div>
|
408 |
-
</div>
|
409 |
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
460 |
-
{/
|
461 |
-
|
462 |
</div>
|
463 |
-
</div>
|
464 |
|
465 |
-
|
466 |
-
|
467 |
-
|
468 |
-
|
|
|
469 |
{/if}
|
470 |
-
|
471 |
-
</Card.
|
472 |
-
</Card.Root>
|
473 |
|
474 |
-
|
475 |
-
|
476 |
-
|
477 |
-
|
478 |
-
|
479 |
-
<
|
480 |
-
|
481 |
-
|
|
|
|
|
|
|
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 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
23 |
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
// Reset when modal closes
|
32 |
-
if (!open) {
|
33 |
-
hasLoadedRooms = false;
|
34 |
-
error = null;
|
35 |
-
}
|
36 |
-
});
|
37 |
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
}
|
45 |
-
}
|
46 |
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
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 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
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 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
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
|
133 |
-
|
134 |
-
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
149 |
{error}
|
150 |
</Alert.Description>
|
151 |
</Alert.Root>
|
152 |
{/if}
|
153 |
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
<
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
{#if video.output.roomId}
|
173 |
-
Broadcasting to Room: {video.output.roomId}
|
174 |
{:else}
|
175 |
-
|
|
|
|
|
176 |
{/if}
|
177 |
</div>
|
178 |
-
|
179 |
-
|
180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
181 |
|
182 |
-
|
183 |
-
|
184 |
-
|
|
|
185 |
<Card.Header>
|
186 |
-
<Card.Title class="flex items-center gap-2 text-base text-
|
187 |
-
<span class="icon-[mdi--
|
188 |
-
|
189 |
</Card.Title>
|
|
|
|
|
|
|
190 |
</Card.Header>
|
191 |
-
<Card.Content>
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
<p class="text-
|
200 |
-
|
201 |
</p>
|
202 |
-
|
203 |
-
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
223 |
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
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 |
-
|
274 |
-
</Card.
|
275 |
-
</Card.Root>
|
276 |
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
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--
|
324 |
-
|
325 |
-
</
|
|
|
|
|
|
|
326 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
327 |
</div>
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
/>
|
345 |
-
<div class="flex gap-1">
|
346 |
<Button
|
347 |
-
variant="
|
348 |
size="sm"
|
349 |
-
onclick={
|
350 |
-
disabled={isConnecting
|
351 |
-
class="h-
|
352 |
>
|
353 |
-
|
|
|
354 |
</Button>
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
359 |
disabled={isConnecting || video?.hasOutput}
|
360 |
-
class="
|
361 |
-
|
362 |
-
|
363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
364 |
</div>
|
365 |
</div>
|
366 |
-
</div>
|
367 |
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
418 |
-
{/
|
419 |
-
|
420 |
</div>
|
421 |
-
</div>
|
422 |
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
|
|
427 |
{/if}
|
428 |
-
|
429 |
-
</Card.
|
430 |
-
</Card.Root>
|
431 |
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
<
|
438 |
-
|
439 |
-
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ===
|
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 ===
|
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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ?
|
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(
|
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(
|
|
|
|
|
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?:
|
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 |
-
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|