obsbot-mcp 0.4.0 → 0.5.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 +88 -15
- package/dist/capture/ffmpeg-args.js +17 -0
- package/dist/capture/ffmpeg-args.js.map +1 -1
- package/dist/codec/commands.d.ts +3 -0
- package/dist/codec/commands.js +16 -0
- package/dist/codec/commands.js.map +1 -1
- package/dist/geometry/aim.d.ts +226 -0
- package/dist/geometry/aim.js +276 -0
- package/dist/geometry/aim.js.map +1 -0
- package/dist/mcp/ready.d.ts +1 -0
- package/dist/mcp/ready.js +3 -1
- package/dist/mcp/ready.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.js +150 -8
- package/dist/mcp/tools.js.map +1 -1
- package/dist/transport/linux.js +17 -1
- package/dist/transport/linux.js.map +1 -1
- package/dist/transport/macos.js +13 -1
- package/dist/transport/macos.js.map +1 -1
- package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
- package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
- package/native/prebuilt/linux-x64/obsbot-helper +0 -0
- package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Pure geometry for aiming the gimbal at a point seen in a snapshot. No I/O, no
|
|
2
|
+
// transport, no device access — everything here is a function of its arguments,
|
|
3
|
+
// which is what makes it testable with no camera attached.
|
|
4
|
+
//
|
|
5
|
+
// Degrees in the public API (matching every other angle in this codebase),
|
|
6
|
+
// radians internal only.
|
|
7
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
8
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
9
|
+
/**
|
|
10
|
+
* Horizontal field of view of the CAPTURE STREAM for each FOV setting, in
|
|
11
|
+
* degrees — DERIVED, not three separate measurements.
|
|
12
|
+
*
|
|
13
|
+
* Earlier revisions carried wide/medium/narrow as three independently measured
|
|
14
|
+
* absolutes at +/-3 degrees each. That threw away the most precise thing known
|
|
15
|
+
* about them: the RATIOS between the modes are measured to ~0.05%, roughly 60x
|
|
16
|
+
* better than any of the absolutes, so three free values let the modes drift out
|
|
17
|
+
* of proportion with each other for no reason.
|
|
18
|
+
*
|
|
19
|
+
* One anchor plus measured magnifications instead. The anchor's uncertainty
|
|
20
|
+
* still propagates, but it now moves all three together, which is the honest
|
|
21
|
+
* representation of what is known.
|
|
22
|
+
*
|
|
23
|
+
* MEASURED 2026-07-25 by solving the camera intrinsics from pure gimbal
|
|
24
|
+
* rotations. A camera that only rotates induces an exact homography
|
|
25
|
+
* H = K R K^-1 between views, with no dependence on scene depth, so frames at
|
|
26
|
+
* known gimbal angles determine the focal lengths outright. No distance is
|
|
27
|
+
* measured anywhere — the gimbal angle is the ruler. That is what makes this
|
|
28
|
+
* tighter than the tape-measured letter sheet behind the old +/-3 degrees.
|
|
29
|
+
*
|
|
30
|
+
* Six rotations (pitch +/-10, +/-20; yaw +/-10) over a static scene, 313-1243
|
|
31
|
+
* inliers each, gave fx = 1452-1455 px on a 1920-wide frame across every subset
|
|
32
|
+
* — a spread of 0.2% — which is HFOV 66.84-66.90. Rounded to 67. This also
|
|
33
|
+
* independently reproduces an earlier pan-and-track measurement of 66.4.
|
|
34
|
+
*
|
|
35
|
+
* The per-mode magnifications come from fitting a similarity transform between
|
|
36
|
+
* frames at each setting on a fixed scene (275 and 683 inliers, 0.48 and 0.44 px
|
|
37
|
+
* residual). They are pure ratios, so they are unaffected by which capture
|
|
38
|
+
* format the measurement went through — which matters, because the 1080p pixel
|
|
39
|
+
* formats do NOT share a field of view (see the note on the capture path below).
|
|
40
|
+
*
|
|
41
|
+
* Verified head-to-head on hardware, same feature and same start pose with only
|
|
42
|
+
* the constant differing: a target at u = +0.91 left a yaw residual of -0.823
|
|
43
|
+
* degrees under the old 68 and -0.274 under 67.
|
|
44
|
+
*
|
|
45
|
+
* CAPTURE FORMAT WARNING: MJPEG 1920x1080 is a 1.201x crop of YUYV 1920x1080 on
|
|
46
|
+
* this camera — same resolution, different window onto the sensor. These
|
|
47
|
+
* constants describe the WIDE field, which is what `obsbot_capture_snapshot`
|
|
48
|
+
* delivers. `obsbot_capture_preview` pins MJPEG and therefore shows ~20% less.
|
|
49
|
+
* Any future measurement through ffmpeg must state its pixel format; a
|
|
50
|
+
* resolution alone does not identify the field.
|
|
51
|
+
*
|
|
52
|
+
* SCOPE: 16:9 capture at any resolution. A 4:3 path would need re-measuring.
|
|
53
|
+
*/
|
|
54
|
+
export const WIDE_HFOV_DEG = 67;
|
|
55
|
+
/**
|
|
56
|
+
* Linear magnification of each FOV setting relative to the wide field.
|
|
57
|
+
*
|
|
58
|
+
* MEASURED 2026-07-25. These are the precise part of the pair: the ratios are
|
|
59
|
+
* good to ~0.05% where the anchor above is good to perhaps 0.5%.
|
|
60
|
+
*
|
|
61
|
+
* The continuous zoom writes to this same scale rather than multiplying on top
|
|
62
|
+
* of it — `narrow` plus zoom ratio 1.5 measures 2.509, the same as `wide` plus
|
|
63
|
+
* 1.5 (2.501), not 1.47 x 2.5. The discrete modes and the zoom control are two
|
|
64
|
+
* ways of writing to one magnification scale, which is why setting zoom ratio
|
|
65
|
+
* 1.0 does not reliably clear `custom`: it is the same optical state as `wide`.
|
|
66
|
+
*/
|
|
67
|
+
export const FOV_MAGNIFICATION = {
|
|
68
|
+
wide: 1,
|
|
69
|
+
medium: 1.15060,
|
|
70
|
+
narrow: 1.47073,
|
|
71
|
+
};
|
|
72
|
+
export const HORIZONTAL_FOV_DEG = {
|
|
73
|
+
wide: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.wide)),
|
|
74
|
+
medium: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.medium)),
|
|
75
|
+
narrow: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.narrow)),
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Empirical correction applied on top of the aspect-derived vertical half-angle.
|
|
79
|
+
* MEASURED, not geometric.
|
|
80
|
+
*
|
|
81
|
+
* Square-pixel geometry says tan(V) = tan(H) * (height/width) — 0.5625 at 16:9.
|
|
82
|
+
* Hardware says the vertical field is shorter than that; this factor carries the
|
|
83
|
+
* difference.
|
|
84
|
+
*
|
|
85
|
+
* MEASURED 2026-07-25 from the same intrinsics solve as WIDE_HFOV_DEG above.
|
|
86
|
+
* fy came out 1502-1520 px across every subset of six rotations, implying this
|
|
87
|
+
* factor at 0.957-0.967. Rounded to 0.957.
|
|
88
|
+
*
|
|
89
|
+
* This REPLACES an earlier value of 0.898, which was 7% low. That figure came
|
|
90
|
+
* from a measurement this project's own history recorded as inconclusive: the
|
|
91
|
+
* constant itself was off by about 6.2%, versus the ~4.3% effect it was trying
|
|
92
|
+
* to capture — roughly 1.4x the effect itself, not an order of magnitude off.
|
|
93
|
+
*
|
|
94
|
+
* The up/down asymmetry is ~1%, not the ~5% once believed: solving from the
|
|
95
|
+
* up-tilt alone gives 0.957 and from the down-tilt alone 0.967. One constant
|
|
96
|
+
* captures that comfortably. Do NOT reintroduce a two-branch vertical constant
|
|
97
|
+
* on the strength of the old figure. Both candidate explanations for a genuine
|
|
98
|
+
* asymmetry were tested and eliminated — the principal point is centred (cx, cy
|
|
99
|
+
* within a few px of frame centre in every solve) and radial distortion is
|
|
100
|
+
* negligible (k1 ~= -0.02).
|
|
101
|
+
*
|
|
102
|
+
* Verified head-to-head on hardware, same feature and same start pose with only
|
|
103
|
+
* this constant differing: a target at v = -0.83 left a pitch residual of -0.597
|
|
104
|
+
* degrees under 0.898 and +0.072 under 0.957, an 8x improvement that lands
|
|
105
|
+
* inside the noise.
|
|
106
|
+
*
|
|
107
|
+
* Known limit: the intrinsics fit carries 2.4-2.8 px rms, not sub-pixel, so
|
|
108
|
+
* something is unmodelled — most likely the entrance pupil sitting off the
|
|
109
|
+
* gimbal's rotation axes, which translates the lens as it turns and is
|
|
110
|
+
* depth-dependent. Sampling was symmetric so it should not bias fx or fy, but do
|
|
111
|
+
* not claim more precision than that.
|
|
112
|
+
*
|
|
113
|
+
* SCOPE: measured on the 16:9 capture path. A 4:3 path needs its own measurement.
|
|
114
|
+
*/
|
|
115
|
+
export const VERTICAL_TANGENT_CORRECTION = 0.957;
|
|
116
|
+
// The tangents are the useful form for every downstream calculation, so they are
|
|
117
|
+
// computed once here and the degree-valued halfAngles() is a thin wrapper. Going
|
|
118
|
+
// through degrees would mean an atan followed immediately by a tan.
|
|
119
|
+
const halfAngleTangents = (optics, frame) => {
|
|
120
|
+
const zoom = optics.zoom ?? 1;
|
|
121
|
+
const tanH = Math.tan(toRad(HORIZONTAL_FOV_DEG[optics.fov] / 2)) / zoom;
|
|
122
|
+
// Vertical starts from the horizontal half-angle scaled by the frame aspect —
|
|
123
|
+
// what square-pixel geometry predicts — then takes the measured correction,
|
|
124
|
+
// because hardware says the real vertical field is ~4.3% shorter than that.
|
|
125
|
+
// See VERTICAL_TANGENT_CORRECTION for the intrinsics solve over six gimbal
|
|
126
|
+
// rotations behind it.
|
|
127
|
+
return { tanH, tanV: tanH * (frame.height / frame.width) * VERTICAL_TANGENT_CORRECTION };
|
|
128
|
+
};
|
|
129
|
+
/** Effective half-angles of the visible field, in degrees, after zoom and aspect. */
|
|
130
|
+
export function halfAngles(optics, frame) {
|
|
131
|
+
const { tanH, tanV } = halfAngleTangents(optics, frame);
|
|
132
|
+
return { h: toDeg(Math.atan(tanH)), v: toDeg(Math.atan(tanV)) };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Convert a pixel in a captured frame to the yaw/pitch delta that would bring
|
|
136
|
+
* that point to the center of frame, treating each axis independently.
|
|
137
|
+
*
|
|
138
|
+
* PER-AXIS ONLY — this does NOT account for axis coupling. The gimbal's yaw
|
|
139
|
+
* axis is world-vertical, so yawing while pitched sweeps a cone; the two axes
|
|
140
|
+
* commute only at zero pitch or zero horizontal offset (see `aimAtPixel`,
|
|
141
|
+
* which composes a rotation instead and is what every tool actually points
|
|
142
|
+
* the camera with). Adding this function's dYaw/dPitch to a pose that is not
|
|
143
|
+
* level reproduces the additive bug `aimAtPixel` was fixed to remove. It is
|
|
144
|
+
* used only by tests now, to pin the per-axis tangent mapping in isolation
|
|
145
|
+
* from the coupling — do not reach for it as a general "point the camera"
|
|
146
|
+
* answer.
|
|
147
|
+
*
|
|
148
|
+
* A rectilinear lens maps angle through a tangent: tan(theta) = u * tan(hfov/2),
|
|
149
|
+
* where u is the normalized offset from center. The linear approximation is
|
|
150
|
+
* exact at the center and again at the edge, and wrong in between — always low,
|
|
151
|
+
* peaking near 1.58 degrees at u ~= 0.55 on the wide setting (WIDE_HFOV_DEG =
|
|
152
|
+
* 67). That error is the difference between landing on target and visibly
|
|
153
|
+
* hunting, so the tangent form is not optional.
|
|
154
|
+
*
|
|
155
|
+
* Signs: +yaw pans camera-LEFT and image x grows rightward, so the yaw term is
|
|
156
|
+
* negated. +pitch tilts DOWN and image y grows downward, so the pitch term is
|
|
157
|
+
* not. Both conventions are hardware-verified.
|
|
158
|
+
*/
|
|
159
|
+
export function pixelToOffset(x, y, frame, optics) {
|
|
160
|
+
const { tanH, tanV } = halfAngleTangents(optics, frame);
|
|
161
|
+
const u = (2 * x) / frame.width - 1;
|
|
162
|
+
const v = (2 * y) / frame.height - 1;
|
|
163
|
+
const uEff = optics.mirrored ? -u : u;
|
|
164
|
+
return {
|
|
165
|
+
dYaw: -toDeg(Math.atan(uEff * tanH)),
|
|
166
|
+
dPitch: toDeg(Math.atan(v * tanV)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Mechanical limits of the Tiny 2's gimbal, in degrees. Hardware-verified.
|
|
171
|
+
*
|
|
172
|
+
* These live here rather than in the tool layer so there is exactly one
|
|
173
|
+
* definition: `obsbot_gimbal_move` imports them for its own clamping. Two copies
|
|
174
|
+
* of a bound that must agree is a defect waiting to happen.
|
|
175
|
+
*
|
|
176
|
+
* ±150 is the mechanical yaw range on EVERY platform, and these limits are not
|
|
177
|
+
* platform-conditional. Measured 2026-07-25: commanded 145 reads back 145, and
|
|
178
|
+
* 150 reads back 149. Position feedback comes from the camera's physical
|
|
179
|
+
* encoder, which is a property of the hardware and does not vary by OS.
|
|
180
|
+
*
|
|
181
|
+
* Do not "fix" this to 130. `transport/linux.ts` and `transport/macos.ts` record
|
|
182
|
+
* a `CT_PANTILT_ABSOLUTE` range of ±468000 arcsec = ±130°, which reads like a
|
|
183
|
+
* conflict and was raised as one during review. It is not: that is the range the
|
|
184
|
+
* UVC control *advertises* — a descriptor value that under-reports the mechanism
|
|
185
|
+
* it describes — not where the gimbal stops. Nothing clamps to it either;
|
|
186
|
+
* `LinuxTransport.gimbalSet` writes `yawDeg * ARCSEC_PER_DEG` unclamped, so the
|
|
187
|
+
* arcsec figure lives only in comments. See the spec's §8.
|
|
188
|
+
*/
|
|
189
|
+
export const GIMBAL_YAW_LIMIT_DEG = 150;
|
|
190
|
+
export const GIMBAL_PITCH_LIMIT_DEG = 90;
|
|
191
|
+
const clampTo = (value, limit) => Math.min(limit, Math.max(-limit, value));
|
|
192
|
+
/**
|
|
193
|
+
* Absolute pose that brings the given pixel to the centre of frame.
|
|
194
|
+
*
|
|
195
|
+
* Composes a rotation rather than adding two scalars. The gimbal's yaw axis is
|
|
196
|
+
* world-vertical, so yawing while pitched sweeps a CONE: the two rotations do
|
|
197
|
+
* not commute, and `target = current + offset` is exact only at zero pitch or
|
|
198
|
+
* zero horizontal offset. Adding them cost 0.98 degrees at pitch 7.6 / yaw 31 on
|
|
199
|
+
* hardware, against `pitch * (1 - cos yaw)` = 1.09 predicted.
|
|
200
|
+
*
|
|
201
|
+
* The ray to the target in camera coordinates (x right, y down, z forward) is
|
|
202
|
+
* rotated into world coordinates by the current orientation, and the target pose
|
|
203
|
+
* is read back off that direction. The two axes reduce to the old additive sum
|
|
204
|
+
* under DIFFERENT conditions, not the same one: yaw reduces exactly to the old
|
|
205
|
+
* sum whenever pitch = 0, for any pixel, because yaw and pitch commute at zero
|
|
206
|
+
* tilt. Pitch reduces to the old sum only when u = 0 (the pixel sits on the
|
|
207
|
+
* vertical centre line) — composed pitch is asin(dy/n) where n includes dx, so
|
|
208
|
+
* away from u = 0 it differs from the old atan(dy) even when pitch is 0. That is
|
|
209
|
+
* what the invariant tests pin.
|
|
210
|
+
*
|
|
211
|
+
* Saturation is REPORTED, not silent: if the target lies outside the gimbal's
|
|
212
|
+
* range the caller has to know it landed short rather than assume the aim
|
|
213
|
+
* succeeded, since a silent clamp presents as "the camera aimed and missed".
|
|
214
|
+
*
|
|
215
|
+
* `current` must be where the camera actually was when the frame was captured.
|
|
216
|
+
* The module cannot verify that — see the spec's section 5 for what breaks it.
|
|
217
|
+
* Note that on Windows the pose the caller reads is FLOORED to whole degrees
|
|
218
|
+
* (spec section 4.2), which costs up to another degree; that is a separate
|
|
219
|
+
* defect in the pose source, not in this function.
|
|
220
|
+
*/
|
|
221
|
+
export function aimAtPixel(x, y, frame, optics, current) {
|
|
222
|
+
const { tanH, tanV } = halfAngleTangents(optics, frame);
|
|
223
|
+
const u = (2 * x) / frame.width - 1;
|
|
224
|
+
const v = (2 * y) / frame.height - 1;
|
|
225
|
+
const uEff = optics.mirrored ? -u : u;
|
|
226
|
+
// Ray to the target, in camera coordinates: x right, y down, z forward.
|
|
227
|
+
const dx = uEff * tanH;
|
|
228
|
+
const dy = v * tanV;
|
|
229
|
+
const dz = 1;
|
|
230
|
+
const n = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
231
|
+
const cy = Math.cos(toRad(current.yaw));
|
|
232
|
+
const sy = Math.sin(toRad(current.yaw));
|
|
233
|
+
const cp = Math.cos(toRad(current.pitch));
|
|
234
|
+
const sp = Math.sin(toRad(current.pitch));
|
|
235
|
+
// Pitch first, about the camera's own x-axis (+pitch tilts DOWN)...
|
|
236
|
+
const px = dx / n;
|
|
237
|
+
const py = (dy * cp + dz * sp) / n;
|
|
238
|
+
const pz = (-dy * sp + dz * cp) / n;
|
|
239
|
+
// ...then yaw, about the world vertical (+yaw pans camera-LEFT).
|
|
240
|
+
const wx = px * cy - pz * sy;
|
|
241
|
+
const wy = py;
|
|
242
|
+
const wz = px * sy + pz * cy;
|
|
243
|
+
const rawPitch = toDeg(Math.asin(Math.max(-1, Math.min(1, wy))));
|
|
244
|
+
// atan2 returns [-180, 180] — -180 IS included, and is exactly the case the
|
|
245
|
+
// following paragraph handles. Pick the representative nearest the current
|
|
246
|
+
// yaw so a target past +150 reads as +183 rather than -177. Without this a
|
|
247
|
+
// saturating aim clamps to the WRONG END of the range.
|
|
248
|
+
//
|
|
249
|
+
// This is a modulo wrap rather than `Math.round((current.yaw - rawYaw) /
|
|
250
|
+
// 360)`: at an exact 180-degree difference — dead behind the camera, which
|
|
251
|
+
// happens for real at u = 0 aiming past vertical — that rounds ties toward
|
|
252
|
+
// +Infinity regardless of sign (JS's Math.round(0.5) is 1, not 0), which
|
|
253
|
+
// silently flips -180 to +180 and clamps on the wrong end. The wrap below
|
|
254
|
+
// always resolves an exact tie to the lower edge of the window instead.
|
|
255
|
+
const rawYawAtan2 = toDeg(Math.atan2(-wx, wz));
|
|
256
|
+
const rawYaw = current.yaw + (((rawYawAtan2 - current.yaw + 180) % 360 + 360) % 360 - 180);
|
|
257
|
+
const yaw = clampTo(rawYaw, GIMBAL_YAW_LIMIT_DEG);
|
|
258
|
+
const pitch = clampTo(rawPitch, GIMBAL_PITCH_LIMIT_DEG);
|
|
259
|
+
// Over the top: the target ray's horizontal direction points opposite the
|
|
260
|
+
// camera's current heading. (hx, hz) is that heading; (wx, wz) is the target
|
|
261
|
+
// direction, both already computed above. A negative dot product means the
|
|
262
|
+
// only way to face the target while keeping the image upright is to go past
|
|
263
|
+
// vertical — clamping the resulting yaw to the nearest limit would slew the
|
|
264
|
+
// camera toward the OPPOSITE side of the room, not toward the target, so the
|
|
265
|
+
// caller must refuse this case rather than clamp-and-move it.
|
|
266
|
+
const hx = -sy;
|
|
267
|
+
const hz = cy;
|
|
268
|
+
const overTheTop = hx * wx + hz * wz < 0;
|
|
269
|
+
return {
|
|
270
|
+
target: { yaw, pitch },
|
|
271
|
+
offset: { dYaw: rawYaw - current.yaw, dPitch: rawPitch - current.pitch },
|
|
272
|
+
clamped: yaw !== rawYaw || pitch !== rawPitch,
|
|
273
|
+
overTheTop,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=aim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aim.js","sourceRoot":"","sources":["../../src/geometry/aim.ts"],"names":[],"mappings":"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"}
|
package/dist/mcp/ready.d.ts
CHANGED
package/dist/mcp/ready.js
CHANGED
|
@@ -48,7 +48,9 @@ export async function ensureReady(getTransport, reconnect, opts = {}) {
|
|
|
48
48
|
return { ok: false, reason: "unreachable", error: `camera not reachable: ${msg(e2)}` };
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
let woke = false;
|
|
51
52
|
if (!awake) {
|
|
53
|
+
woke = true;
|
|
52
54
|
await t.sendVendor(encodeSetRunStatus("run").buildFrame(t.nextSeq()));
|
|
53
55
|
let waited = 0;
|
|
54
56
|
while (waited < wakeTimeoutMs) {
|
|
@@ -69,6 +71,6 @@ export async function ensureReady(getTransport, reconnect, opts = {}) {
|
|
|
69
71
|
}
|
|
70
72
|
await sleep(settleMs); // let the gimbal finish rising before we drive it
|
|
71
73
|
}
|
|
72
|
-
return { ok: true, transport: t, reconnected: reconnect?.takeReconnected() ?? false };
|
|
74
|
+
return { ok: true, transport: t, reconnected: reconnect?.takeReconnected() ?? false, woke };
|
|
73
75
|
}
|
|
74
76
|
//# sourceMappingURL=ready.js.map
|
package/dist/mcp/ready.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ready.js","sourceRoot":"","sources":["../../src/mcp/ready.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"ready.js","sourceRoot":"","sources":["../../src/mcp/ready.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AA0B1D,MAAM,QAAQ,GAAwB,EAAE,MAAM,EAAE,GAAG,EAAE,aAAa,EAAE,IAAI,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC;AAE1F,MAAM,KAAK,GAAG,CAAC,EAAU,EAAiB,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;AACnF,gFAAgF;AAChF,gFAAgF;AAChF,gDAAgD;AAChD,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,CAAU,EAAU,EAAE,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;AAExF,MAAM,SAAS,GAAG,KAAK,EAAE,CAAkB,EAAoB,EAAE,CAC/D,YAAY,CAAC,MAAM,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC;AAE3C;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,YAA4C,EAC5C,SAAwB,EACxB,OAAkB,EAAE;IAEpB,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,EAAE,GAAG,EAAE,GAAG,QAAQ,EAAE,GAAG,IAAI,EAAE,CAAC;IAErE,IAAI,CAAkB,CAAC;IACvB,IAAI,CAAC;QACH,CAAC,GAAG,MAAM,YAAY,EAAE,CAAC;IAC3B,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,qBAAqB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACpF,CAAC;IAED,IAAI,KAAc,CAAC;IACnB,IAAI,CAAC;QACH,KAAK,GAAG,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC;IAC7B,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,oEAAoE;QACpE,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,yBAAyB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QACxF,CAAC;QACD,MAAM,SAAS,CAAC,UAAU,EAAE,CAAC;QAC7B,IAAI,CAAC;YACH,CAAC,GAAG,MAAM,YAAY,EAAE,CAAC;YACzB,KAAK,GAAG,MAAM,SAAS,CAAC,CAAC,CAAC,CAAC;QAC7B,CAAC;QAAC,OAAO,EAAE,EAAE,CAAC;YACZ,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,KAAK,EAAE,yBAAyB,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QACzF,CAAC;IACH,CAAC;IAED,IAAI,IAAI,GAAG,KAAK,CAAC;IACjB,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,IAAI,GAAG,IAAI,CAAC;QACZ,MAAM,CAAC,CAAC,UAAU,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACtE,IAAI,MAAM,GAAG,CAAC,CAAC;QACf,OAAO,MAAM,GAAG,aAAa,EAAE,CAAC;YAC9B,MAAM,KAAK,CAAC,MAAM,CAAC,CAAC;YACpB,MAAM,IAAI,MAAM,CAAC;YACjB,IAAI,CAAC;gBACH,IAAI,MAAM,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;oBACvB,KAAK,GAAG,IAAI,CAAC;oBACb,MAAM;gBACR,CAAC;YACH,CAAC;YAAC,MAAM,CAAC;gBACP,+DAA+D;YACjE,CAAC;QACH,CAAC;QACD,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAC;QAC5F,CAAC;QACD,MAAM,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,kDAAkD;IAC3E,CAAC;IAED,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,eAAe,EAAE,IAAI,KAAK,EAAE,IAAI,EAAE,CAAC;AAC9F,CAAC"}
|
package/dist/mcp/server.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function startServer(opts = {}) {
|
|
|
71
71
|
process.on("SIGTERM", () => { shutdown(); process.exit(0); });
|
|
72
72
|
const server = new Server(
|
|
73
73
|
// Must match package.json's version; test/version-sync.test.ts enforces it.
|
|
74
|
-
{ name: "obsbot-mcp", version: "0.
|
|
74
|
+
{ name: "obsbot-mcp", version: "0.5.0" }, { capabilities: { tools: {} } });
|
|
75
75
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
76
76
|
tools: tools.map((tool) => ({
|
|
77
77
|
name: tool.name,
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { encodeSetRunStatus, zoomRatioToUnits, encodeAiTrackSpeed, encodeAiTracking, encodeAiMode, encodeFaceAe, encodeVendorProbe, encodeVendorGet, encodeZoomWithSpeed, encodeFaceFocus, encodeSetExposure, decodeStatus, encodeFov, encodeHdr, percentToRange, AI_FRAMING_MODES, AI_SCENE_MODES, AI_TRACK_SPEEDS, FOV_TYPES, UVC_XU_SELECTOR, CAMERA_CONTROL_PAN, CAMERA_CONTROL_TILT, CAMERA_CONTROL_FOCUS, VIDEO_PROCAMP_WHITE_BALANCE, IMAGE_CONTROL_PROP, IMAGE_CONTROLS, UVC_FLAG_AUTO, UVC_FLAG_MANUAL, } from "../codec/commands.js";
|
|
3
|
+
import { aimAtPixel, GIMBAL_YAW_LIMIT_DEG, GIMBAL_PITCH_LIMIT_DEG } from "../geometry/aim.js";
|
|
3
4
|
import { verifyFraming } from "./framing.js";
|
|
4
5
|
import { parseFrame } from "../codec/frame.js";
|
|
5
6
|
import { OP_BY_NAME } from "../codec/opcodes.js";
|
|
@@ -113,6 +114,12 @@ const imageExposureManualSchema = withCamera({
|
|
|
113
114
|
level: num().pipe(z.number().min(0).max(100)).default(50),
|
|
114
115
|
});
|
|
115
116
|
const gimbalPositionSchema = withCamera({});
|
|
117
|
+
const aimAtPixelSchema = withCamera({
|
|
118
|
+
x: num().pipe(z.number().finite()),
|
|
119
|
+
y: num().pipe(z.number().finite()),
|
|
120
|
+
frameWidth: num().pipe(z.number().finite().min(1)),
|
|
121
|
+
frameHeight: num().pipe(z.number().finite().min(1)),
|
|
122
|
+
});
|
|
116
123
|
const presetListSchema = withCamera({});
|
|
117
124
|
const presetSaveSchema = withCamera({
|
|
118
125
|
slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
|
|
@@ -261,6 +268,13 @@ async function getPresetSlots(t) {
|
|
|
261
268
|
// window. Latency is the cheapest thing we can spend here.
|
|
262
269
|
const PRESET_READ_DEFAULTS = { attempts: 3, backoffMs: [200, 500, 1000] };
|
|
263
270
|
const napMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
271
|
+
// Pan/tilt readback is a float now (see transport/linux.ts, transport/macos.ts),
|
|
272
|
+
// which can carry many more digits than the hardware's ±1° accuracy justifies —
|
|
273
|
+
// e.g. 5.975277777777778 from an arc-second division. Round to 2 decimal places
|
|
274
|
+
// for reporting: enough to show sub-degree structure (useful on Linux, where it
|
|
275
|
+
// reflects the last commanded pose) without implying precision the readout
|
|
276
|
+
// doesn't have.
|
|
277
|
+
const round2 = (deg) => Math.round(deg * 100) / 100;
|
|
264
278
|
const allEmpty = (slots) => slots.every((s) => !s.occupied);
|
|
265
279
|
/**
|
|
266
280
|
* Read the preset slots with bounded retry, and confirm the one verdict whose error
|
|
@@ -375,13 +389,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
375
389
|
{
|
|
376
390
|
name: "obsbot_gimbal_move",
|
|
377
391
|
description: "Move the gimbal to an absolute yaw/pitch angle (degrees); positive yaw pans to the " +
|
|
378
|
-
|
|
379
|
-
|
|
392
|
+
`camera's left, positive pitch tilts down. Yaw is clamped to ` +
|
|
393
|
+
`[-${GIMBAL_YAW_LIMIT_DEG},${GIMBAL_YAW_LIMIT_DEG}], pitch to ` +
|
|
394
|
+
`[-${GIMBAL_PITCH_LIMIT_DEG},${GIMBAL_PITCH_LIMIT_DEG}]. ` +
|
|
395
|
+
"Absolute positioning (1:1 degrees), verified on hardware.",
|
|
380
396
|
schema: ptzMoveAngleSchema,
|
|
381
397
|
handler: async (args) => {
|
|
382
398
|
const parsed = ptzMoveAngleSchema.parse(args);
|
|
383
|
-
const yaw = clamp(parsed.yaw, -
|
|
384
|
-
const pitch = clamp(parsed.pitch, -
|
|
399
|
+
const yaw = clamp(parsed.yaw, -GIMBAL_YAW_LIMIT_DEG, GIMBAL_YAW_LIMIT_DEG);
|
|
400
|
+
const pitch = clamp(parsed.pitch, -GIMBAL_PITCH_LIMIT_DEG, GIMBAL_PITCH_LIMIT_DEG);
|
|
385
401
|
const roll = parsed.roll;
|
|
386
402
|
const ready = await gate(parsed.camera);
|
|
387
403
|
if (!ready.ok)
|
|
@@ -545,10 +561,13 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
545
561
|
},
|
|
546
562
|
{
|
|
547
563
|
name: "obsbot_status",
|
|
548
|
-
description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed
|
|
564
|
+
description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed, " +
|
|
565
|
+
"fovMode, zoomPercent }: " +
|
|
549
566
|
"faceAe is whether auto-exposure is metering for a detected face; " +
|
|
550
567
|
"aiMode is the current AI framing (no-tracking|normal|upper-body|close-up|headless|" +
|
|
551
|
-
"lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown
|
|
568
|
+
"lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown; " +
|
|
569
|
+
"fovMode is the field-of-view mode (wide|medium|narrow|custom|unknown), where custom means " +
|
|
570
|
+
"a continuous zoom overrode the discrete modes; zoomPercent is the zoom position, 0-100. " +
|
|
552
571
|
"Under --debug the result also carries `raw`: the full 60-byte status block as hex " +
|
|
553
572
|
"(for reverse-engineering undecoded offsets).",
|
|
554
573
|
schema: getStatusSchema,
|
|
@@ -696,7 +715,8 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
696
715
|
name: "obsbot_gimbal_position",
|
|
697
716
|
description: "Read the gimbal's current absolute yaw/pitch in degrees (positive yaw = camera's " +
|
|
698
717
|
"left, positive pitch = down) via the standard UVC Pan/Tilt controls. This is a live " +
|
|
699
|
-
"hardware readout accurate to ±1
|
|
718
|
+
"hardware readout accurate to ±1°, reported rounded to 2 decimal places (finer digits " +
|
|
719
|
+
"would be noise, not precision): it is valid during a move as well as after one, " +
|
|
700
720
|
"and reflects motion the host did not command (speed moves, recenter, tracking).",
|
|
701
721
|
schema: gimbalPositionSchema,
|
|
702
722
|
handler: async (args) => {
|
|
@@ -706,7 +726,129 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
706
726
|
const tilt = await t.camCtrlGet(CAMERA_CONTROL_TILT);
|
|
707
727
|
// UVC pan value is degrees, same sign as our yaw (+ = camera-left). UVC tilt
|
|
708
728
|
// is degrees but positive = up, so negate to match our +pitch = down convention.
|
|
709
|
-
return { yaw: pan.value, pitch: -tilt.value };
|
|
729
|
+
return { yaw: round2(pan.value), pitch: round2(-tilt.value) };
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: "obsbot_aim_at_pixel",
|
|
734
|
+
description: "Point the camera at a specific pixel in a frame you just captured. Give the pixel's x/y " +
|
|
735
|
+
"and the frameWidth/frameHeight from THE SAME obsbot_capture_snapshot result — mixing a " +
|
|
736
|
+
"pixel from one frame with dimensions from another aims at the wrong place and cannot be " +
|
|
737
|
+
"detected. The frame must come from a source:\"device\" snapshot — virtual and ndi frames " +
|
|
738
|
+
"are framed by OBSBOT Center, not this camera's own optics, and will aim wrongly. Takes no " +
|
|
739
|
+
"field-of-view argument: it reads the camera's actual FOV mode. Refuses when AI tracking is " +
|
|
740
|
+
"active (tracking moves the gimbal itself and would fight the aim) and when a custom zoom " +
|
|
741
|
+
"is set (the zoom magnification is measured but not applied yet), so it never aims on an assumption it " +
|
|
742
|
+
"cannot check. If the camera was asleep, waking it moves the gimbal and invalidates the " +
|
|
743
|
+
"frame you measured, so the call refuses instead of aiming on stale geometry — take a fresh " +
|
|
744
|
+
"snapshot and retry. Returns clamped:true if the target was outside the gimbal's range; the " +
|
|
745
|
+
"camera still moves, to the nearest reachable pose. Refuses (ok:false) instead of moving when " +
|
|
746
|
+
"the pixel lies past vertical from the current pose — reachable only by an \"over the top\" " +
|
|
747
|
+
"rotation that would swing the camera toward the opposite side of the room, not toward the " +
|
|
748
|
+
"target; tilt toward the pixel first, then re-aim.",
|
|
749
|
+
schema: aimAtPixelSchema,
|
|
750
|
+
handler: async (args) => {
|
|
751
|
+
const { x, y, frameWidth, frameHeight, camera } = aimAtPixelSchema.parse(args);
|
|
752
|
+
// 16:9 is preserved by the capture path at every resolution (verified at
|
|
753
|
+
// 256x144, 1280x720, 1920x1080), so a non-16:9 pair did not come from that
|
|
754
|
+
// path as-is — it was transposed (width/height swapped) or came from
|
|
755
|
+
// somewhere else entirely, since passing the values through unchanged
|
|
756
|
+
// always yields 16:9.
|
|
757
|
+
if (Math.abs(frameWidth / frameHeight - 16 / 9) > 0.02) {
|
|
758
|
+
return {
|
|
759
|
+
ok: false,
|
|
760
|
+
error: `frame ${frameWidth}x${frameHeight} is not 16:9, but obsbot_capture_snapshot always ` +
|
|
761
|
+
`returns 16:9 frames. This looks like frameWidth/frameHeight were transposed, or came ` +
|
|
762
|
+
`from something other than that tool's result.`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
if (x < 0 || x > frameWidth || y < 0 || y > frameHeight) {
|
|
766
|
+
return { ok: false, error: `pixel (${x},${y}) is outside the ${frameWidth}x${frameHeight} frame` };
|
|
767
|
+
}
|
|
768
|
+
// The gate wakes a sleeping camera and waits for it to settle. If it had
|
|
769
|
+
// to wake the camera, the gimbal just moved out from under the frame the
|
|
770
|
+
// caller measured — refuse below rather than read a pose that no longer
|
|
771
|
+
// corresponds to that frame.
|
|
772
|
+
const ready = await gate(camera);
|
|
773
|
+
if (!ready.ok)
|
|
774
|
+
return ready;
|
|
775
|
+
const t = ready.transport;
|
|
776
|
+
if (ready.woke) {
|
|
777
|
+
return {
|
|
778
|
+
ok: false,
|
|
779
|
+
error: "the camera was asleep and waking it moved the gimbal, so the frame you measured no " +
|
|
780
|
+
"longer matches where the camera is pointing. Take a fresh snapshot and aim again.",
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const block = await t.recvStatus();
|
|
784
|
+
const status = decodeStatus(block);
|
|
785
|
+
if (status.aiMode === "unknown") {
|
|
786
|
+
return {
|
|
787
|
+
ok: false,
|
|
788
|
+
error: "could not read the camera's AI-tracking mode (the status block didn't decode this " +
|
|
789
|
+
"read — this can happen during a brief mode-switch transient); retry the aim.",
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
if (status.aiMode !== "no-tracking") {
|
|
793
|
+
return {
|
|
794
|
+
ok: false,
|
|
795
|
+
error: `AI tracking is active (${status.aiMode}); it moves the gimbal itself and would ` +
|
|
796
|
+
`fight the aim. Disable it with obsbot_ai_track {enabled:false} first.`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
if (status.fovMode === "custom") {
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
error: `a custom zoom is active (zoom ${status.zoomPercent}%). The zoom-to-magnification ` +
|
|
803
|
+
`mapping IS measured — magnification is 3*ratio-2, so this zoom is about ` +
|
|
804
|
+
`${(1 + 0.03 * status.zoomPercent).toFixed(2)}x — but it is not applied here yet, and ` +
|
|
805
|
+
`aiming without it would be wrong by that factor. Set obsbot_image_fov {fov:"wide"} ` +
|
|
806
|
+
`(or any discrete mode) to clear it. obsbot_zoom_uvc {ratio:1} will NOT clear it: ` +
|
|
807
|
+
`ratio 1.0 is the same optical state as wide, so the camera stays in custom mode.`,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
if (status.fovMode === "unknown") {
|
|
811
|
+
// block[0x11] is STATUS_OFF_FOV_MODE (src/codec/commands.ts) — same offset
|
|
812
|
+
// decodeStatus reads to produce fovMode, surfaced raw since "unknown" means
|
|
813
|
+
// it didn't match any known value.
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
error: `could not read the camera's FOV mode (raw byte 0x${block[0x11].toString(16).padStart(2, "0")}); ` +
|
|
817
|
+
`refusing to guess it. Set a known mode with obsbot_image_fov (e.g. {fov:"wide"}) and retry.`,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
// Same read path as obsbot_gimbal_position: UVC pan is degrees with our
|
|
821
|
+
// yaw sign; UVC tilt is degrees but positive = up, so negate it.
|
|
822
|
+
const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
|
|
823
|
+
const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
|
|
824
|
+
// zoom:1 — the measured FOV constants already include each discrete
|
|
825
|
+
// mode's inherent crop, so applying zoomPercent again would double-count.
|
|
826
|
+
const aim = aimAtPixel(x, y, { width: frameWidth, height: frameHeight }, { fov: status.fovMode, zoom: 1 }, { yaw, pitch });
|
|
827
|
+
// Over the top is not an ordinary clamp: the target ray points behind
|
|
828
|
+
// the camera's current heading, so the nearest reachable yaw is on the
|
|
829
|
+
// FAR side of the gimbal's range, not near the target. Moving there
|
|
830
|
+
// would slew ~150 degrees into the opposite corner of the room while
|
|
831
|
+
// reporting clamped:true, which reads as "landed short" rather than
|
|
832
|
+
// "went the wrong way" — refuse instead of moving.
|
|
833
|
+
if (aim.overTheTop) {
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
error: `pixel (${x},${y}) lies past vertical from the current pose (yaw ${yaw.toFixed(1)}, ` +
|
|
837
|
+
`pitch ${pitch.toFixed(1)}); it is not reachable while keeping the image upright, only ` +
|
|
838
|
+
`by an "over the top" rotation that would swing the camera the opposite way. Tilt toward ` +
|
|
839
|
+
`the pixel first (e.g. obsbot_gimbal_move), then re-aim from the new pose.`,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
await t.gimbalSet(aim.target.yaw, aim.target.pitch, 0);
|
|
843
|
+
return {
|
|
844
|
+
ok: true,
|
|
845
|
+
target: aim.target,
|
|
846
|
+
offset: aim.offset,
|
|
847
|
+
clamped: aim.clamped,
|
|
848
|
+
fovMode: status.fovMode,
|
|
849
|
+
current: { yaw, pitch },
|
|
850
|
+
...(ready.reconnected ? { reconnected: true } : {}),
|
|
851
|
+
};
|
|
710
852
|
},
|
|
711
853
|
},
|
|
712
854
|
{
|