obsbot-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -170
- package/dist/capture/ffmpeg-args.d.ts +13 -2
- package/dist/capture/ffmpeg-args.js +61 -4
- package/dist/capture/ffmpeg-args.js.map +1 -1
- package/dist/capture/manager.d.ts +7 -1
- package/dist/capture/manager.js +59 -15
- package/dist/capture/manager.js.map +1 -1
- package/dist/codec/commands.d.ts +10 -0
- package/dist/codec/commands.js +21 -0
- package/dist/codec/commands.js.map +1 -1
- package/dist/device/manager.d.ts +1 -0
- package/dist/device/manager.js +25 -4
- package/dist/device/manager.js.map +1 -1
- package/dist/mcp/tools.js +21 -11
- package/dist/mcp/tools.js.map +1 -1
- package/dist/transport/helper-process.js +4 -1
- package/dist/transport/helper-process.js.map +1 -1
- package/dist/transport/linux.d.ts +40 -0
- package/dist/transport/linux.js +66 -0
- package/dist/transport/linux.js.map +1 -0
- package/native/prebuilt/linux-x64/obsbot-helper +0 -0
- package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
- package/package.json +53 -33
package/README.md
CHANGED
|
@@ -1,170 +1,186 @@
|
|
|
1
|
-
# obsbot-mcp
|
|
2
|
-
|
|
3
|
-
A cross-platform [Model Context Protocol](https://modelcontextprotocol.io) server that controls an
|
|
4
|
-
**OBSBOT Tiny 2** camera over its standard UVC/USB interface — pan/tilt/roll the gimbal, zoom, AI
|
|
5
|
-
subject tracking, focus/exposure/white-balance/image controls, HDR and field-of-view, plus snapshot,
|
|
6
|
-
preview, and recording — without any vendor SDK.
|
|
7
|
-
|
|
8
|
-
## Install
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
npm install obsbot-mcp
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## MCP client configuration
|
|
15
|
-
|
|
16
|
-
Add a stdio server entry pointing at the installed binary (or directly at `dist/index.js`):
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"mcpServers": {
|
|
21
|
-
"obsbot": {
|
|
22
|
-
"command": "obsbot-mcp"
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
If you're running from a local checkout instead of an npm install, point `command`/`args` at
|
|
29
|
-
`node` and the built entry point instead:
|
|
30
|
-
|
|
31
|
-
```json
|
|
32
|
-
{
|
|
33
|
-
"mcpServers": {
|
|
34
|
-
"obsbot": {
|
|
35
|
-
"command": "node",
|
|
36
|
-
"args": ["path/to/obsbot-mcp/dist/index.js"]
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### Debug / diagnostics tools
|
|
43
|
-
|
|
44
|
-
By default the server advertises only the normal control surface. Pass `--debug` to additionally
|
|
45
|
-
expose the diagnostics surface — the `obsbot_probe` tool (raw XU byte get/set/query) and the
|
|
46
|
-
`raw` 60-byte status block on `obsbot_get_status`:
|
|
47
|
-
|
|
48
|
-
```json
|
|
49
|
-
{
|
|
50
|
-
"mcpServers": {
|
|
51
|
-
"obsbot": {
|
|
52
|
-
"command": "node",
|
|
53
|
-
"args": ["path/to/obsbot-mcp/dist/index.js", "--debug"]
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
With the installed binary, use `"command": "obsbot-mcp"` and `"args": ["--debug"]`.
|
|
60
|
-
|
|
61
|
-
## Tools
|
|
62
|
-
|
|
63
|
-
### Device & power
|
|
64
|
-
|
|
65
|
-
| Tool | Parameters | Description |
|
|
66
|
-
|------|------------|-------------|
|
|
67
|
-
| `obsbot_list_devices` | — | List connected OBSBOT-compatible video capture devices. |
|
|
68
|
-
| `obsbot_set_run_status` | `state`: `"run" \| "sleep"` | Wake (`"run"`) or sleep the camera/gimbal. |
|
|
69
|
-
| `obsbot_get_status` | — | Read the live status block: `{ awake, hdr, aiMode, trackSpeed }`. Under `--debug`, also returns the raw 60-byte block as hex. |
|
|
70
|
-
|
|
71
|
-
### Gimbal (PTZ)
|
|
72
|
-
|
|
73
|
-
| Tool | Parameters | Description |
|
|
74
|
-
|------|------------|-------------|
|
|
75
|
-
| `obsbot_ptz_move_angle` | `yaw`, `pitch`, `roll` (degrees, `roll` defaults `0`) | Move the gimbal to an absolute angle. Positive yaw pans to the camera's left, positive pitch tilts down. Yaw clamped to `[-150, 150]`, pitch to `[-90, 90]`. Absolute 1:1 degrees, hardware-verified. |
|
|
76
|
-
| `obsbot_ptz_move_speed` | `yaw`, `pitch`, `roll` (deg/s, `roll` defaults `0`), `autoStopMs` (default `800`) | Drive the gimbal at a speed, then auto-stop after `autoStopMs` so it can't run away. Same yaw/pitch sign convention as `move_angle`. |
|
|
77
|
-
| `obsbot_gimbal_recenter` | — | Recenter the gimbal (return to home position). |
|
|
78
|
-
| `obsbot_gimbal_position` | — | Read the gimbal's current absolute `{ yaw, pitch }` in degrees via standard UVC Pan/Tilt. May lag a move still in progress. |
|
|
79
|
-
|
|
80
|
-
### Zoom
|
|
81
|
-
|
|
82
|
-
| Tool | Parameters | Description |
|
|
83
|
-
|------|------------|-------------|
|
|
84
|
-
| `obsbot_zoom_absolute` | `ratio` (`1.0`–`2.0`) | Set absolute zoom ratio, clamped to `[1.0, 2.0]`. |
|
|
85
|
-
| `obsbot_zoom_speed` | `ratio` (`1.0`–`2.0`), `speed` (default `0`) | Zoom to a ratio at a chosen speed: `0` = device default, `1`–`10` slow→fast, `255` = maximum. |
|
|
86
|
-
|
|
87
|
-
### AI tracking
|
|
88
|
-
|
|
89
|
-
| Tool | Parameters | Description |
|
|
90
|
-
|------|------------|-------------|
|
|
91
|
-
| `obsbot_ai_tracking` | `enabled` (bool), `mode` (default `"normal"`) | Enable/disable AI subject tracking and choose framing: `normal \| upper-body \| close-up \| headless \| lower-body`. Polls status and returns `{ verified, matched }` (`matched:false` = no subject tracked yet). |
|
|
92
|
-
| `obsbot_ai_track_speed` | `speed`: `"standard" \| "sport"` | Set the tracking-speed preset (Center's Standard/Sport): `standard` (slower follow) or `sport` (snappier). |
|
|
93
|
-
| `obsbot_face_focus` | `enabled` (bool) | Enable or disable face-priority autofocus. |
|
|
94
|
-
|
|
95
|
-
### Image & lens
|
|
96
|
-
|
|
97
|
-
| Tool | Parameters | Description |
|
|
98
|
-
|------|------------|-------------|
|
|
99
|
-
| `obsbot_fov` | `fov`: `"wide" \| "medium" \| "narrow"` | Set the field of view: wide (86°), medium (78°), narrow (65°). |
|
|
100
|
-
| `obsbot_hdr` | `enabled` (bool) | Toggle HDR/WDR imaging on or off. |
|
|
101
|
-
| `obsbot_focus` | `mode`: `"auto" \| "manual"`, `position` (`0`–`100`, default `50`) | `auto` = continuous autofocus; `manual` = set the focus motor to `position` (near→far). |
|
|
102
|
-
| `obsbot_exposure` | `mode`: `"auto" \| "manual"`, `level` (`0`–`100`, default `50`) | `auto` = auto-exposure; `manual` = set `level` (0 darkest → 100 brightest). |
|
|
103
|
-
| `obsbot_white_balance` | `mode`: `"auto" \| "manual"`, `temperature` (Kelvin, default `5000`) | `auto` = auto white balance; `manual` = set a colour temperature (clamped to device range). |
|
|
104
|
-
| `obsbot_image_control` | `control`, `level` (`0`–`100`) | Adjust `brightness \| contrast \| hue \| saturation \| sharpness \| gain \| backlight-compensation`; `level` maps onto the device range. |
|
|
105
|
-
|
|
106
|
-
### Capture
|
|
107
|
-
|
|
108
|
-
| Tool | Parameters | Description |
|
|
109
|
-
|------|------------|-------------|
|
|
110
|
-
| `obsbot_snapshot` | `maxDim` (`256`–`1920`, default `1024`), `quality` (`1`–`100`, default `80`), `settleMs` (default `600`), `source` (default `"device"`) | Grab one still frame and return it as an image (for framing/lighting/exposure checks). `source`: `device \| virtual \| ndi`. |
|
|
111
|
-
| `obsbot_record_start` | `durationSec` (optional), `audio` (default `true`), `outputPath` (optional), `source` (default `"device"`) | Start recording to MP4. Open-ended recordings auto-stop after 60 min; audio uses the OBSBOT mic; defaults under `Videos/OBSBOT`. Returns a `sessionId`. **Needs ffmpeg.**¹ |
|
|
112
|
-
| `obsbot_preview_start` | `source` (default `"device"`) | Open a live preview window. Returns a `sessionId`. **Needs ffplay.**¹ |
|
|
113
|
-
| `obsbot_capture_stop` | `sessionId` | Stop a recording or preview session (recordings are finalized gracefully). |
|
|
114
|
-
| `obsbot_capture_list` | — | List active recording/preview sessions. |
|
|
115
|
-
|
|
116
|
-
### Diagnostics (`--debug` only)
|
|
117
|
-
|
|
118
|
-
| Tool | Parameters | Description |
|
|
119
|
-
|------|------------|-------------|
|
|
120
|
-
| `obsbot_probe` | `mode`: `"get" \| "set" \| "query"`, plus `selector`, `length`, `hex`, `opcode`, `payloadHex` | RE/diagnostics only — raw XU byte get/set and framed table queries. Advertised only under `--debug`. |
|
|
121
|
-
|
|
122
|
-
¹ `record`/`preview` shell out to **ffmpeg**/**ffplay** (install: `winget install Gyan.FFmpeg`
|
|
123
|
-
on Windows, `brew install ffmpeg` on macOS, `apt install ffmpeg` on Linux). `snapshot` does **not**
|
|
124
|
-
need ffmpeg — it grabs the frame through the native helper.
|
|
125
|
-
|
|
126
|
-
## Supported platforms
|
|
127
|
-
|
|
128
|
-
- **Windows x64** — supported today. The native helper is built from source in `native/windows/`
|
|
129
|
-
(CMake + MSVC); the published npm package ships a prebuilt binary so end users need no toolchain.
|
|
130
|
-
- **Linux
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1
|
+
# obsbot-mcp
|
|
2
|
+
|
|
3
|
+
A cross-platform [Model Context Protocol](https://modelcontextprotocol.io) server that controls an
|
|
4
|
+
**OBSBOT Tiny 2** camera over its standard UVC/USB interface — pan/tilt/roll the gimbal, zoom, AI
|
|
5
|
+
subject tracking, focus/exposure/white-balance/image controls, HDR and field-of-view, plus snapshot,
|
|
6
|
+
preview, and recording — without any vendor SDK.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install obsbot-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## MCP client configuration
|
|
15
|
+
|
|
16
|
+
Add a stdio server entry pointing at the installed binary (or directly at `dist/index.js`):
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"mcpServers": {
|
|
21
|
+
"obsbot": {
|
|
22
|
+
"command": "obsbot-mcp"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
If you're running from a local checkout instead of an npm install, point `command`/`args` at
|
|
29
|
+
`node` and the built entry point instead:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"obsbot": {
|
|
35
|
+
"command": "node",
|
|
36
|
+
"args": ["path/to/obsbot-mcp/dist/index.js"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Debug / diagnostics tools
|
|
43
|
+
|
|
44
|
+
By default the server advertises only the normal control surface. Pass `--debug` to additionally
|
|
45
|
+
expose the diagnostics surface — the `obsbot_probe` tool (raw XU byte get/set/query) and the
|
|
46
|
+
`raw` 60-byte status block on `obsbot_get_status`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"obsbot": {
|
|
52
|
+
"command": "node",
|
|
53
|
+
"args": ["path/to/obsbot-mcp/dist/index.js", "--debug"]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
With the installed binary, use `"command": "obsbot-mcp"` and `"args": ["--debug"]`.
|
|
60
|
+
|
|
61
|
+
## Tools
|
|
62
|
+
|
|
63
|
+
### Device & power
|
|
64
|
+
|
|
65
|
+
| Tool | Parameters | Description |
|
|
66
|
+
|------|------------|-------------|
|
|
67
|
+
| `obsbot_list_devices` | — | List connected OBSBOT-compatible video capture devices. |
|
|
68
|
+
| `obsbot_set_run_status` | `state`: `"run" \| "sleep"` | Wake (`"run"`) or sleep the camera/gimbal. |
|
|
69
|
+
| `obsbot_get_status` | — | Read the live status block: `{ awake, hdr, aiMode, trackSpeed }`. Under `--debug`, also returns the raw 60-byte block as hex. |
|
|
70
|
+
|
|
71
|
+
### Gimbal (PTZ)
|
|
72
|
+
|
|
73
|
+
| Tool | Parameters | Description |
|
|
74
|
+
|------|------------|-------------|
|
|
75
|
+
| `obsbot_ptz_move_angle` | `yaw`, `pitch`, `roll` (degrees, `roll` defaults `0`) | Move the gimbal to an absolute angle. Positive yaw pans to the camera's left, positive pitch tilts down. Yaw clamped to `[-150, 150]`, pitch to `[-90, 90]`. Absolute 1:1 degrees, hardware-verified. |
|
|
76
|
+
| `obsbot_ptz_move_speed` | `yaw`, `pitch`, `roll` (deg/s, `roll` defaults `0`), `autoStopMs` (default `800`) | Drive the gimbal at a speed, then auto-stop after `autoStopMs` so it can't run away. Same yaw/pitch sign convention as `move_angle`. |
|
|
77
|
+
| `obsbot_gimbal_recenter` | — | Recenter the gimbal (return to home position). |
|
|
78
|
+
| `obsbot_gimbal_position` | — | Read the gimbal's current absolute `{ yaw, pitch }` in degrees via standard UVC Pan/Tilt. May lag a move still in progress. |
|
|
79
|
+
|
|
80
|
+
### Zoom
|
|
81
|
+
|
|
82
|
+
| Tool | Parameters | Description |
|
|
83
|
+
|------|------------|-------------|
|
|
84
|
+
| `obsbot_zoom_absolute` | `ratio` (`1.0`–`2.0`) | Set absolute zoom ratio, clamped to `[1.0, 2.0]`. |
|
|
85
|
+
| `obsbot_zoom_speed` | `ratio` (`1.0`–`2.0`), `speed` (default `0`) | Zoom to a ratio at a chosen speed: `0` = device default, `1`–`10` slow→fast, `255` = maximum. |
|
|
86
|
+
|
|
87
|
+
### AI tracking
|
|
88
|
+
|
|
89
|
+
| Tool | Parameters | Description |
|
|
90
|
+
|------|------------|-------------|
|
|
91
|
+
| `obsbot_ai_tracking` | `enabled` (bool), `mode` (default `"normal"`) | Enable/disable AI subject tracking and choose framing: `normal \| upper-body \| close-up \| headless \| lower-body`. Polls status and returns `{ verified, matched }` (`matched:false` = no subject tracked yet). |
|
|
92
|
+
| `obsbot_ai_track_speed` | `speed`: `"standard" \| "sport"` | Set the tracking-speed preset (Center's Standard/Sport): `standard` (slower follow) or `sport` (snappier). |
|
|
93
|
+
| `obsbot_face_focus` | `enabled` (bool) | Enable or disable face-priority autofocus. |
|
|
94
|
+
|
|
95
|
+
### Image & lens
|
|
96
|
+
|
|
97
|
+
| Tool | Parameters | Description |
|
|
98
|
+
|------|------------|-------------|
|
|
99
|
+
| `obsbot_fov` | `fov`: `"wide" \| "medium" \| "narrow"` | Set the field of view: wide (86°), medium (78°), narrow (65°). |
|
|
100
|
+
| `obsbot_hdr` | `enabled` (bool) | Toggle HDR/WDR imaging on or off. |
|
|
101
|
+
| `obsbot_focus` | `mode`: `"auto" \| "manual"`, `position` (`0`–`100`, default `50`) | `auto` = continuous autofocus; `manual` = set the focus motor to `position` (near→far). |
|
|
102
|
+
| `obsbot_exposure` | `mode`: `"auto" \| "manual"`, `level` (`0`–`100`, default `50`) | `auto` = auto-exposure; `manual` = set `level` (0 darkest → 100 brightest). |
|
|
103
|
+
| `obsbot_white_balance` | `mode`: `"auto" \| "manual"`, `temperature` (Kelvin, default `5000`) | `auto` = auto white balance; `manual` = set a colour temperature (clamped to device range). |
|
|
104
|
+
| `obsbot_image_control` | `control`, `level` (`0`–`100`) | Adjust `brightness \| contrast \| hue \| saturation \| sharpness \| gain \| backlight-compensation`; `level` maps onto the device range. |
|
|
105
|
+
|
|
106
|
+
### Capture
|
|
107
|
+
|
|
108
|
+
| Tool | Parameters | Description |
|
|
109
|
+
|------|------------|-------------|
|
|
110
|
+
| `obsbot_snapshot` | `maxDim` (`256`–`1920`, default `1024`), `quality` (`1`–`100`, default `80`), `settleMs` (default `600`), `source` (default `"device"`) | Grab one still frame and return it as an image (for framing/lighting/exposure checks). `source`: `device \| virtual \| ndi`. |
|
|
111
|
+
| `obsbot_record_start` | `durationSec` (optional), `audio` (default `true`), `outputPath` (optional), `source` (default `"device"`) | Start recording to MP4. Open-ended recordings auto-stop after 60 min; audio uses the OBSBOT mic; defaults under `Videos/OBSBOT`. Returns a `sessionId`. **Needs ffmpeg.**¹ |
|
|
112
|
+
| `obsbot_preview_start` | `source` (default `"device"`) | Open a live preview window. Returns a `sessionId`. **Needs ffplay.**¹ |
|
|
113
|
+
| `obsbot_capture_stop` | `sessionId` | Stop a recording or preview session (recordings are finalized gracefully). |
|
|
114
|
+
| `obsbot_capture_list` | — | List active recording/preview sessions. |
|
|
115
|
+
|
|
116
|
+
### Diagnostics (`--debug` only)
|
|
117
|
+
|
|
118
|
+
| Tool | Parameters | Description |
|
|
119
|
+
|------|------------|-------------|
|
|
120
|
+
| `obsbot_probe` | `mode`: `"get" \| "set" \| "query"`, plus `selector`, `length`, `hex`, `opcode`, `payloadHex` | RE/diagnostics only — raw XU byte get/set and framed table queries. Advertised only under `--debug`. |
|
|
121
|
+
|
|
122
|
+
¹ `record`/`preview` shell out to **ffmpeg**/**ffplay** (install: `winget install Gyan.FFmpeg`
|
|
123
|
+
on Windows, `brew install ffmpeg` on macOS, `apt install ffmpeg` on Linux). `snapshot` does **not**
|
|
124
|
+
need ffmpeg — it grabs the frame through the native helper.
|
|
125
|
+
|
|
126
|
+
## Supported platforms
|
|
127
|
+
|
|
128
|
+
- **Windows x64** — supported today. The native helper is built from source in `native/windows/`
|
|
129
|
+
(CMake + MSVC); the published npm package ships a prebuilt binary so end users need no toolchain.
|
|
130
|
+
- **Linux x64** — supported from v0.2. The native helper is in `native/linux/` (CMake + GCC);
|
|
131
|
+
it uses **V4L2** for standard UVC controls (zoom, focus, exposure, pan/tilt, white balance,
|
|
132
|
+
image controls) and `UVCIOC_CTRL_QUERY` for vendor Extension Unit commands (gimbal, AI tracking,
|
|
133
|
+
wake/sleep, HDR, FOV). Snapshots capture a MJPEG or YUYV frame via V4L2 mmap streaming and encode
|
|
134
|
+
to JPEG using **libjpeg**. The `linux-x64` prebuilt binary ships with the published npm package.
|
|
135
|
+
Build dependencies: `build-essential cmake libjpeg-dev libv4l-dev`.
|
|
136
|
+
- **macOS** — not yet implemented. The design is platform-agnostic (see [`PROTOCOL.md`](./PROTOCOL.md));
|
|
137
|
+
adding support means writing an equivalent native helper for each OS's UVC control APIs
|
|
138
|
+
(`AVFoundation`/`IOKit` on macOS) behind the same JSON-RPC-over-stdio contract used by the existing
|
|
139
|
+
Windows and Linux helpers. Contributions welcome.
|
|
140
|
+
|
|
141
|
+
### Building the native helper (Linux)
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd native/linux
|
|
145
|
+
mkdir build && cd build
|
|
146
|
+
cmake ..
|
|
147
|
+
make -j$(nproc)
|
|
148
|
+
make install # copies to native/prebuilt/linux-x64/
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## No proprietary SDK
|
|
152
|
+
|
|
153
|
+
This project speaks the camera's USB protocol directly through the OS's standard UVC driver stack and
|
|
154
|
+
does **not** use, link, bundle, or ship any vendor SDK. See [`PROTOCOL.md`](./PROTOCOL.md) for the
|
|
155
|
+
protocol reference (frame format, checksum, command table).
|
|
156
|
+
|
|
157
|
+
## How it works
|
|
158
|
+
|
|
159
|
+
The camera exposes two independent control surfaces, both reachable through the OS's standard UVC
|
|
160
|
+
(USB Video Class) driver stack — this project never talks to the USB device directly, so the OS keeps
|
|
161
|
+
mediating access and the camera remains usable as a normal webcam at the same time commands are sent:
|
|
162
|
+
|
|
163
|
+
- **Standard UVC controls** — zoom (`CT_ZOOM_ABSOLUTE`), focus and exposure (`IAMCameraControl`),
|
|
164
|
+
gimbal position readback (UVC Pan/Tilt), and the image controls plus white balance
|
|
165
|
+
(`IAMVideoProcAmp`) — are the camera's built-in UVC properties, driven via DirectShow on Windows.
|
|
166
|
+
- **Vendor commands** — gimbal moves, recenter, wake/sleep, AI tracking, HDR, and field of view —
|
|
167
|
+
are sent through the camera's UVC Extension Unit, driven via `IKsControl::KsProperty` against the
|
|
168
|
+
XU's topology node on Windows.
|
|
169
|
+
|
|
170
|
+
Both are issued through a small native helper process (`obsbot-helper.exe` on Windows, `obsbot-helper`
|
|
171
|
+
on Linux) that the Node server spawns and talks to over a line-delimited JSON-RPC protocol on
|
|
172
|
+
stdin/stdout. The helper is the only platform-specific piece; the codec (frame encoding, CRC-16/USB
|
|
173
|
+
checksum, command table), transport abstraction, device manager, and MCP tool definitions are all pure
|
|
174
|
+
TypeScript/JavaScript and shared across platforms.
|
|
175
|
+
|
|
176
|
+
## Verifying against real hardware
|
|
177
|
+
|
|
178
|
+
`scripts/e2e.mjs` drives the built stack (`dist/`) against a physically connected camera: it wakes the
|
|
179
|
+
device, zooms in, pans the gimbal, recenters, zooms back out, and puts the camera to sleep, with a short
|
|
180
|
+
pause and console log before each step so a human can watch it happen. **This moves the physical gimbal —
|
|
181
|
+
only run it under supervision:**
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run build
|
|
185
|
+
node scripts/e2e.mjs
|
|
186
|
+
```
|
|
@@ -3,9 +3,20 @@ export interface DshowDevices {
|
|
|
3
3
|
video: string[];
|
|
4
4
|
audio: string[];
|
|
5
5
|
}
|
|
6
|
+
export interface V4l2DeviceInfo {
|
|
7
|
+
path: string;
|
|
8
|
+
card: string;
|
|
9
|
+
}
|
|
10
|
+
export interface V4l2Devices {
|
|
11
|
+
video: V4l2DeviceInfo[];
|
|
12
|
+
audio: string[];
|
|
13
|
+
}
|
|
14
|
+
export type DeviceList = DshowDevices | V4l2Devices;
|
|
6
15
|
export declare function parseDshowDevices(stderr: string): DshowDevices;
|
|
7
|
-
export declare function resolveVideoName(devices:
|
|
8
|
-
export declare function resolveAudioName(devices:
|
|
16
|
+
export declare function resolveVideoName(devices: DeviceList, source: CaptureSource): string | undefined;
|
|
17
|
+
export declare function resolveAudioName(devices: DeviceList): string | undefined;
|
|
18
|
+
/** Parse the v4l2 device name from `ffmpeg -f v4l2 -i /dev/videoN` stderr. */
|
|
19
|
+
export declare function parseV4l2DeviceName(stderr: string): string | undefined;
|
|
9
20
|
export declare function buildRecordArgs(o: {
|
|
10
21
|
videoName: string;
|
|
11
22
|
audioName?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
// ---------- Windows (dshow) helpers ----------
|
|
1
2
|
// ffmpeg -f dshow -list_devices prints one line per device on stderr:
|
|
2
3
|
// [dshow @ ..] "Friendly Name" (video)
|
|
3
|
-
// The name is the first quoted span; the trailing "(video)"/"(audio)" is the type.
|
|
4
4
|
export function parseDshowDevices(stderr) {
|
|
5
5
|
const video = [];
|
|
6
6
|
const audio = [];
|
|
@@ -16,16 +16,61 @@ export function parseDshowDevices(stderr) {
|
|
|
16
16
|
return { video, audio };
|
|
17
17
|
}
|
|
18
18
|
export function resolveVideoName(devices, source) {
|
|
19
|
-
|
|
19
|
+
const isV4l = (d) => "video" in d && d.video.length > 0 && typeof d.video[0] !== "string";
|
|
20
|
+
if (source === "virtual") {
|
|
21
|
+
if (isV4l(devices)) {
|
|
22
|
+
return devices.video.find((n) => /OBSBOT Virtual Camera/i.test(n.card))?.path;
|
|
23
|
+
}
|
|
20
24
|
return devices.video.find((n) => /OBSBOT Virtual Camera/i.test(n));
|
|
21
|
-
|
|
25
|
+
}
|
|
26
|
+
if (source === "ndi") {
|
|
27
|
+
if (isV4l(devices)) {
|
|
28
|
+
return devices.video.find((n) => /NDI Webcam/i.test(n.card))?.path;
|
|
29
|
+
}
|
|
22
30
|
return devices.video.find((n) => /NDI Webcam/i.test(n));
|
|
31
|
+
}
|
|
32
|
+
// Platform-specific device matching
|
|
33
|
+
if (isV4l(devices)) {
|
|
34
|
+
const found = devices.video.find((n) => /OBSBOT/i.test(n.card));
|
|
35
|
+
return found ? found.path : undefined;
|
|
36
|
+
}
|
|
37
|
+
// dshow on Windows
|
|
23
38
|
return devices.video.find((n) => /OBSBOT Tiny 2/i.test(n) && !/Virtual/i.test(n));
|
|
24
39
|
}
|
|
25
40
|
export function resolveAudioName(devices) {
|
|
26
|
-
|
|
41
|
+
if ("audio" in devices && Array.isArray(devices.audio)) {
|
|
42
|
+
return devices.audio.find((n) => /OBSBOT.*Mic/i.test(n));
|
|
43
|
+
}
|
|
44
|
+
// Linux v4l2 audio is typically a separate ALSA device
|
|
45
|
+
// Return undefined — callers should check and warn
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
// ---------- Linux (v4l2) helpers ----------
|
|
49
|
+
// ffmpeg -f v4l2 -list_formats all -i /dev/videoN lists formats on stderr.
|
|
50
|
+
/** Parse the v4l2 device name from `ffmpeg -f v4l2 -i /dev/videoN` stderr. */
|
|
51
|
+
export function parseV4l2DeviceName(stderr) {
|
|
52
|
+
// ffmpeg prints something like:
|
|
53
|
+
// [video4linux2,v4l2 @ 0x...] VideoDevice: /dev/videoN
|
|
54
|
+
// [video4linux2,v4l2 @ 0x...] driver : uvcvideo
|
|
55
|
+
// [video4linux2,v4l2 @ 0x...] card : OBSBOT Tiny 2 ...
|
|
56
|
+
const m = stderr.match(/card\s+:\s+(.+)/);
|
|
57
|
+
return m ? m[1].trim() : undefined;
|
|
27
58
|
}
|
|
28
59
|
export function buildRecordArgs(o) {
|
|
60
|
+
const isV4l2 = o.videoName.startsWith("/dev/");
|
|
61
|
+
if (isV4l2) {
|
|
62
|
+
return [
|
|
63
|
+
"-hide_banner", "-loglevel", "warning",
|
|
64
|
+
"-f", "v4l2",
|
|
65
|
+
"-i", o.videoName,
|
|
66
|
+
...(o.audioName ? ["-f", "alsa", "-i", o.audioName] : []),
|
|
67
|
+
"-t", String(o.durationSec),
|
|
68
|
+
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
|
69
|
+
...(o.audioName ? ["-c:a", "aac"] : []),
|
|
70
|
+
"-y", o.outputPath,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
// dshow path (Windows)
|
|
29
74
|
const input = o.audioName
|
|
30
75
|
? `video=${o.videoName}:audio=${o.audioName}`
|
|
31
76
|
: `video=${o.videoName}`;
|
|
@@ -39,6 +84,18 @@ export function buildRecordArgs(o) {
|
|
|
39
84
|
];
|
|
40
85
|
}
|
|
41
86
|
export function buildPreviewArgs(o) {
|
|
87
|
+
const isV4l2 = o.videoName.startsWith("/dev/");
|
|
88
|
+
if (isV4l2) {
|
|
89
|
+
return [
|
|
90
|
+
"-hide_banner", "-loglevel", "warning",
|
|
91
|
+
"-f", "v4l2",
|
|
92
|
+
"-input_format", "mjpeg",
|
|
93
|
+
"-video_size", "1920x1080",
|
|
94
|
+
"-i", o.videoName,
|
|
95
|
+
"-window_title", "OBSBOT preview",
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
// dshow path
|
|
42
99
|
return [
|
|
43
100
|
"-hide_banner", "-loglevel", "warning", "-f", "dshow",
|
|
44
101
|
"-i", `video=${o.videoName}`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ffmpeg-args.js","sourceRoot":"","sources":["../../src/capture/ffmpeg-args.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ffmpeg-args.js","sourceRoot":"","sources":["../../src/capture/ffmpeg-args.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn as realSpawn } from "node:child_process";
|
|
2
|
-
import { type CaptureSource } from "./ffmpeg-args.js";
|
|
2
|
+
import { type CaptureSource, type DeviceList } from "./ffmpeg-args.js";
|
|
3
3
|
export interface CaptureSession {
|
|
4
4
|
id: string;
|
|
5
5
|
kind: "record" | "preview";
|
|
@@ -27,18 +27,24 @@ interface Deps {
|
|
|
27
27
|
clock?: () => string;
|
|
28
28
|
hasBinary?: (name: string) => boolean;
|
|
29
29
|
fs?: FsLike;
|
|
30
|
+
/** Override for device probe logic (used by tests to avoid real V4L2/dshow probing). */
|
|
31
|
+
probeDevices?: () => Promise<DeviceList>;
|
|
30
32
|
}
|
|
31
33
|
export declare class CaptureManager {
|
|
32
34
|
private readonly spawn;
|
|
33
35
|
private readonly clock;
|
|
34
36
|
private readonly hasBinary;
|
|
35
37
|
private readonly fs;
|
|
38
|
+
private readonly probeFn;
|
|
36
39
|
private readonly sessions;
|
|
37
40
|
private devices?;
|
|
38
41
|
private seq;
|
|
39
42
|
constructor(deps?: Deps);
|
|
40
43
|
private ensureFfmpeg;
|
|
41
44
|
private probeDevices;
|
|
45
|
+
private probeV4l2Devices;
|
|
46
|
+
private probeV4l2Card;
|
|
47
|
+
private probeDshowDevices;
|
|
42
48
|
private timestampName;
|
|
43
49
|
startRecord(o: {
|
|
44
50
|
source: CaptureSource;
|
package/dist/capture/manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn as realSpawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { parseDshowDevices, resolveVideoName, resolveAudioName, buildRecordArgs, buildPreviewArgs, } from "./ffmpeg-args.js";
|
|
@@ -29,6 +29,7 @@ export class CaptureManager {
|
|
|
29
29
|
clock;
|
|
30
30
|
hasBinary;
|
|
31
31
|
fs;
|
|
32
|
+
probeFn;
|
|
32
33
|
sessions = new Map();
|
|
33
34
|
devices;
|
|
34
35
|
seq = 0;
|
|
@@ -37,6 +38,7 @@ export class CaptureManager {
|
|
|
37
38
|
this.clock = deps.clock ?? (() => new Date().toISOString());
|
|
38
39
|
this.hasBinary = deps.hasBinary ?? defaultHasBinary;
|
|
39
40
|
this.fs = deps.fs ?? { existsSync, mkdirSync };
|
|
41
|
+
this.probeFn = deps.probeDevices;
|
|
40
42
|
}
|
|
41
43
|
ensureFfmpeg() {
|
|
42
44
|
if (!this.hasBinary("ffmpeg") || !this.hasBinary("ffplay")) {
|
|
@@ -46,19 +48,64 @@ export class CaptureManager {
|
|
|
46
48
|
probeDevices() {
|
|
47
49
|
if (this.devices)
|
|
48
50
|
return Promise.resolve(this.devices);
|
|
51
|
+
// Allow tests to inject a mock probe
|
|
52
|
+
if (this.probeFn)
|
|
53
|
+
return this.probeFn();
|
|
49
54
|
return new Promise((resolve, reject) => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
if (process.platform === "linux") {
|
|
56
|
+
// On Linux, list /dev/video* devices and probe each one
|
|
57
|
+
this.probeV4l2Devices().then(resolve).catch(reject);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
this.probeDshowDevices(resolve, reject);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async probeV4l2Devices() {
|
|
65
|
+
const video = [];
|
|
66
|
+
try {
|
|
67
|
+
const files = readdirSync("/dev");
|
|
68
|
+
const videoDevs = files
|
|
69
|
+
.filter((f) => /^video\d+$/.test(f))
|
|
70
|
+
.map((f) => `/dev/${f}`)
|
|
71
|
+
.sort();
|
|
72
|
+
for (const dev of videoDevs) {
|
|
73
|
+
const name = await this.probeV4l2Card(dev);
|
|
74
|
+
if (name)
|
|
75
|
+
video.push({ path: dev, card: name });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// /dev not readable
|
|
80
|
+
}
|
|
81
|
+
return { video, audio: [] };
|
|
82
|
+
}
|
|
83
|
+
probeV4l2Card(dev) {
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
try {
|
|
86
|
+
const idx = dev.match(/video(\d+)$/)?.[1];
|
|
87
|
+
if (!idx)
|
|
88
|
+
return resolve(undefined);
|
|
89
|
+
const sysfs = `/sys/class/video4linux/video${idx}/name`;
|
|
90
|
+
const name = readFileSync(sysfs, 'utf-8').trim();
|
|
91
|
+
resolve(name || undefined);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
resolve(undefined);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
probeDshowDevices(resolve, reject) {
|
|
99
|
+
const child = this.spawn("ffmpeg", ["-hide_banner", "-f", "dshow", "-list_devices", "true", "-i", "dummy"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
100
|
+
let err = "";
|
|
101
|
+
child.stderr?.on("data", (d) => { err += d.toString(); });
|
|
102
|
+
child.on("error", (e) => reject(new CaptureError(`failed to run ffmpeg for device probe: ${e.message}`)));
|
|
103
|
+
child.on("close", () => {
|
|
104
|
+
this.devices = parseDshowDevices(err);
|
|
105
|
+
resolve(this.devices);
|
|
58
106
|
});
|
|
59
107
|
}
|
|
60
108
|
timestampName() {
|
|
61
|
-
// clock() -> "2026-07-13T09:33:58.000Z"; make obsbot-YYYYMMDD-HHMMSS.mp4
|
|
62
109
|
const iso = this.clock();
|
|
63
110
|
const compact = iso.replace(/[-:]/g, "").replace("T", "-").replace(/\..*$/, "");
|
|
64
111
|
return `obsbot-${compact}.mp4`;
|
|
@@ -116,7 +163,6 @@ export class CaptureManager {
|
|
|
116
163
|
const { session, child } = entry;
|
|
117
164
|
let graceful = true;
|
|
118
165
|
if (session.kind === "record") {
|
|
119
|
-
// 'q' on stdin is ffmpeg's clean stop (finalizes the MP4 moov atom).
|
|
120
166
|
graceful = await new Promise((resolve) => {
|
|
121
167
|
let done = false;
|
|
122
168
|
const timer = setTimeout(() => { if (!done) {
|
|
@@ -142,12 +188,10 @@ export class CaptureManager {
|
|
|
142
188
|
return { kind: session.kind, outputPath: session.outputPath, graceful };
|
|
143
189
|
}
|
|
144
190
|
list() {
|
|
145
|
-
return
|
|
191
|
+
return Array.from(this.sessions.values()).map((e) => e.session);
|
|
146
192
|
}
|
|
147
|
-
// Hard-kill every child. Wired to server shutdown so nothing orphans; this is
|
|
148
|
-
// last-resort cleanup, not a graceful stop.
|
|
149
193
|
stopAll() {
|
|
150
|
-
for (const { child } of this.sessions.values()) {
|
|
194
|
+
for (const { child } of Array.from(this.sessions.values())) {
|
|
151
195
|
try {
|
|
152
196
|
child.kill();
|
|
153
197
|
}
|