obsbot-mcp 0.4.1 → 0.6.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.
@@ -0,0 +1,259 @@
1
+ import type { FovType } from "../codec/commands.js";
2
+ export interface Frame {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ export interface Pose {
7
+ yaw: number;
8
+ pitch: number;
9
+ }
10
+ export interface Optics {
11
+ /**
12
+ * Total linear magnification relative to the WIDE field, which is 1.0.
13
+ *
14
+ * One number, deliberately. The camera has a single magnification scale: the
15
+ * discrete FOV modes are points on it (FOV_MAGNIFICATION) and a continuous
16
+ * zoom writes to it directly (magnificationFromZoomRatio). Setting a zoom
17
+ * ratio REPLACES the mode's magnification rather than multiplying it —
18
+ * `narrow` plus ratio 1.5 measures 2.509, the same as `wide` plus 1.5, not
19
+ * 1.47 x 2.5. An earlier `{ fov, zoom }` shape made that double-counting easy
20
+ * to write and had to warn against it; this shape makes it unrepresentable.
21
+ */
22
+ magnification: number;
23
+ /**
24
+ * Whether the capture path horizontally flips the preview. This inverts the
25
+ * yaw correction, so it is an explicit input rather than a baked-in
26
+ * assumption. Defaults to false.
27
+ */
28
+ mirrored?: boolean;
29
+ }
30
+ /**
31
+ * Horizontal field of view of the CAPTURE STREAM for each FOV setting, in
32
+ * degrees — DERIVED, not three separate measurements.
33
+ *
34
+ * Earlier revisions carried wide/medium/narrow as three independently measured
35
+ * absolutes at +/-3 degrees each. That threw away the most precise thing known
36
+ * about them: the RATIOS between the modes are measured to ~0.05%, roughly 60x
37
+ * better than any of the absolutes, so three free values let the modes drift out
38
+ * of proportion with each other for no reason.
39
+ *
40
+ * One anchor plus measured magnifications instead. The anchor's uncertainty
41
+ * still propagates, but it now moves all three together, which is the honest
42
+ * representation of what is known.
43
+ *
44
+ * MEASURED 2026-07-25 by solving the camera intrinsics from pure gimbal
45
+ * rotations. A camera that only rotates induces an exact homography
46
+ * H = K R K^-1 between views, with no dependence on scene depth, so frames at
47
+ * known gimbal angles determine the focal lengths outright. No distance is
48
+ * measured anywhere — the gimbal angle is the ruler. That is what makes this
49
+ * tighter than the tape-measured letter sheet behind the old +/-3 degrees.
50
+ *
51
+ * Six rotations (pitch +/-10, +/-20; yaw +/-10) over a static scene, 313-1243
52
+ * inliers each, gave fx = 1452-1455 px on a 1920-wide frame across every subset
53
+ * — a spread of 0.2% — which is HFOV 66.84-66.90. Rounded to 67. This also
54
+ * independently reproduces an earlier pan-and-track measurement of 66.4.
55
+ *
56
+ * The per-mode magnifications come from fitting a similarity transform between
57
+ * frames at each setting on a fixed scene (275 and 683 inliers, 0.48 and 0.44 px
58
+ * residual). They are pure ratios, so they are unaffected by which capture
59
+ * format the measurement went through — which matters, because the 1080p pixel
60
+ * formats do NOT share a field of view (see the note on the capture path below).
61
+ *
62
+ * Verified head-to-head on hardware, same feature and same start pose with only
63
+ * the constant differing: a target at u = +0.91 left a yaw residual of -0.823
64
+ * degrees under the old 68 and -0.274 under 67.
65
+ *
66
+ * CAPTURE FORMAT WARNING: at 1920x1080 this camera has two different windows
67
+ * onto the sensor, and FRAME RATE selects between them — not the codec.
68
+ * MEASURED 2026-07-25 at one pose and one zoom: MJPEG@30 vs YUYV@30 came out at
69
+ * scale 1.00001, t (0.2, 0.0) over 2382 inliers at 0.12 px, i.e. the same field
70
+ * to within a fifth of a pixel; MJPEG@60 is a 1.214x crop of BOTH (1.21422 and
71
+ * 1.21404). An earlier revision recorded this as "MJPEG is a 1.201x crop of
72
+ * YUYV" — that comparison was MJPEG@60 against YUYV@30 and charged the codec
73
+ * for what the frame rate did.
74
+ *
75
+ * These constants describe the WIDE (30fps) field. That is what
76
+ * `obsbot_capture_snapshot` delivers: its graph negotiates MJPG 1920x1080@30,
77
+ * which the reply now states outright in `sourceFormat`.
78
+ * `obsbot_capture_preview` pins 60fps to buy smooth motion and therefore shows
79
+ * ~21% less — so preview pixels are NOT interchangeable with snapshot pixels
80
+ * for aiming.
81
+ *
82
+ * Any future measurement must state pixel format AND frame rate; neither a
83
+ * resolution nor a codec alone identifies the field.
84
+ *
85
+ * SCOPE: 16:9 capture at any resolution. A 4:3 path would need re-measuring.
86
+ */
87
+ export declare const WIDE_HFOV_DEG = 67;
88
+ /**
89
+ * Linear magnification of each FOV setting relative to the wide field.
90
+ *
91
+ * MEASURED 2026-07-25. These are the precise part of the pair: the ratios are
92
+ * good to ~0.05% where the anchor above is good to perhaps 0.5%.
93
+ *
94
+ * The continuous zoom writes to this same scale rather than multiplying on top
95
+ * of it — `narrow` plus zoom ratio 1.5 measures 2.509, the same as `wide` plus
96
+ * 1.5 (2.501), not 1.47 x 2.5. The discrete modes and the zoom control are two
97
+ * ways of writing to one magnification scale, which is why setting zoom ratio
98
+ * 1.0 does not reliably clear `custom`: it is the same optical state as `wide`.
99
+ */
100
+ export declare const FOV_MAGNIFICATION: Record<FovType, number>;
101
+ export declare const HORIZONTAL_FOV_DEG: Record<FovType, number>;
102
+ /**
103
+ * Empirical correction applied on top of the aspect-derived vertical half-angle.
104
+ * MEASURED, not geometric.
105
+ *
106
+ * Square-pixel geometry says tan(V) = tan(H) * (height/width) — 0.5625 at 16:9.
107
+ * Hardware says the vertical field is shorter than that; this factor carries the
108
+ * difference.
109
+ *
110
+ * MEASURED 2026-07-25 from the same intrinsics solve as WIDE_HFOV_DEG above.
111
+ * fy came out 1502-1520 px across every subset of six rotations, implying this
112
+ * factor at 0.957-0.967. Rounded to 0.957.
113
+ *
114
+ * This REPLACES an earlier value of 0.898, which was 7% low. That figure came
115
+ * from a measurement this project's own history recorded as inconclusive: the
116
+ * constant itself was off by about 6.2%, versus the ~4.3% effect it was trying
117
+ * to capture — roughly 1.4x the effect itself, not an order of magnitude off.
118
+ *
119
+ * The up/down asymmetry is ~1%, not the ~5% once believed: solving from the
120
+ * up-tilt alone gives 0.957 and from the down-tilt alone 0.967. One constant
121
+ * captures that comfortably. Do NOT reintroduce a two-branch vertical constant
122
+ * on the strength of the old figure. Both candidate explanations for a genuine
123
+ * asymmetry were tested and eliminated — the principal point is centred (cx, cy
124
+ * within a few px of frame centre in every solve) and radial distortion is
125
+ * negligible (k1 ~= -0.02).
126
+ *
127
+ * Verified head-to-head on hardware, same feature and same start pose with only
128
+ * this constant differing: a target at v = -0.83 left a pitch residual of -0.597
129
+ * degrees under 0.898 and +0.072 under 0.957, an 8x improvement that lands
130
+ * inside the noise.
131
+ *
132
+ * Known limit: the intrinsics fit carries 2.4-2.8 px rms, not sub-pixel, so
133
+ * something is unmodelled — most likely the entrance pupil sitting off the
134
+ * gimbal's rotation axes, which translates the lens as it turns and is
135
+ * depth-dependent. Sampling was symmetric so it should not bias fx or fy, but do
136
+ * not claim more precision than that.
137
+ *
138
+ * SCOPE: measured on the 16:9 capture path. A 4:3 path needs its own measurement.
139
+ */
140
+ export declare const VERTICAL_TANGENT_CORRECTION = 0.957;
141
+ /** Magnification of the wide field, and of the whole scale, at its extremes. */
142
+ export declare const MIN_MAGNIFICATION = 1;
143
+ export declare const MAX_MAGNIFICATION = 4;
144
+ /**
145
+ * Linear magnification for a UVC zoom ratio. MEASURED 2026-07-25: magnification
146
+ * is linear in the ratio, `m = 3r - 2`, holding to better than 0.05% at ratios
147
+ * 1.25, 1.5 and 2.0. So ratio 2.0 is 4x linear — carried for a long time as an
148
+ * unsourced note, now measured to four figures. See the spec's section 1.1.
149
+ */
150
+ export declare const magnificationFromZoomRatio: (ratio: number) => number;
151
+ /** Inverse of {@link magnificationFromZoomRatio}: the ratio that yields `m`. */
152
+ export declare const zoomRatioFromMagnification: (m: number) => number;
153
+ /** Effective half-angles of the visible field, in degrees, after zoom and aspect. */
154
+ export declare function halfAngles(optics: Optics, frame: Frame): {
155
+ h: number;
156
+ v: number;
157
+ };
158
+ export interface Offset {
159
+ dYaw: number;
160
+ dPitch: number;
161
+ }
162
+ /**
163
+ * Convert a pixel in a captured frame to the yaw/pitch delta that would bring
164
+ * that point to the center of frame, treating each axis independently.
165
+ *
166
+ * PER-AXIS ONLY — this does NOT account for axis coupling. The gimbal's yaw
167
+ * axis is world-vertical, so yawing while pitched sweeps a cone; the two axes
168
+ * commute only at zero pitch or zero horizontal offset (see `aimAtPixel`,
169
+ * which composes a rotation instead and is what every tool actually points
170
+ * the camera with). Adding this function's dYaw/dPitch to a pose that is not
171
+ * level reproduces the additive bug `aimAtPixel` was fixed to remove. It is
172
+ * used only by tests now, to pin the per-axis tangent mapping in isolation
173
+ * from the coupling — do not reach for it as a general "point the camera"
174
+ * answer.
175
+ *
176
+ * A rectilinear lens maps angle through a tangent: tan(theta) = u * tan(hfov/2),
177
+ * where u is the normalized offset from center. The linear approximation is
178
+ * exact at the center and again at the edge, and wrong in between — always low,
179
+ * peaking near 1.58 degrees at u ~= 0.55 on the wide setting (WIDE_HFOV_DEG =
180
+ * 67). That error is the difference between landing on target and visibly
181
+ * hunting, so the tangent form is not optional.
182
+ *
183
+ * Signs: +yaw pans camera-LEFT and image x grows rightward, so the yaw term is
184
+ * negated. +pitch tilts DOWN and image y grows downward, so the pitch term is
185
+ * not. Both conventions are hardware-verified.
186
+ */
187
+ export declare function pixelToOffset(x: number, y: number, frame: Frame, optics: Optics): Offset;
188
+ /**
189
+ * Mechanical limits of the Tiny 2's gimbal, in degrees. Hardware-verified.
190
+ *
191
+ * These live here rather than in the tool layer so there is exactly one
192
+ * definition: `obsbot_gimbal_move` imports them for its own clamping. Two copies
193
+ * of a bound that must agree is a defect waiting to happen.
194
+ *
195
+ * ±150 is the mechanical yaw range on EVERY platform, and these limits are not
196
+ * platform-conditional. Measured 2026-07-25: commanded 145 reads back 145, and
197
+ * 150 reads back 149. Position feedback comes from the camera's physical
198
+ * encoder, which is a property of the hardware and does not vary by OS.
199
+ *
200
+ * Do not "fix" this to 130. `transport/linux.ts` and `transport/macos.ts` record
201
+ * a `CT_PANTILT_ABSOLUTE` range of ±468000 arcsec = ±130°, which reads like a
202
+ * conflict and was raised as one during review. It is not: that is the range the
203
+ * UVC control *advertises* — a descriptor value that under-reports the mechanism
204
+ * it describes — not where the gimbal stops. Nothing clamps to it either;
205
+ * `LinuxTransport.gimbalSet` writes `yawDeg * ARCSEC_PER_DEG` unclamped, so the
206
+ * arcsec figure lives only in comments. See the spec's §8.
207
+ */
208
+ export declare const GIMBAL_YAW_LIMIT_DEG = 150;
209
+ export declare const GIMBAL_PITCH_LIMIT_DEG = 90;
210
+ export interface Aim {
211
+ target: Pose;
212
+ /**
213
+ * The rotation REQUESTED to reach the raw, unclamped target —
214
+ * `rawTarget - current` — not the rotation actually applied. When `clamped`
215
+ * is true, `target` stops short of `current + offset`; `offset` still
216
+ * reports what was asked for, not what the gimbal was told to do.
217
+ */
218
+ offset: Offset;
219
+ /** True if either axis saturated, meaning the target was not reachable. */
220
+ clamped: boolean;
221
+ /**
222
+ * True when the target ray's horizontal direction points opposite the
223
+ * camera's current heading — an "over the top" solution. The pixel lies
224
+ * past vertical from the current pose and is not reachable by any rotation
225
+ * that keeps the image upright; clamping it to the nearest yaw would slew
226
+ * the camera toward the far side of the room, not toward the target.
227
+ */
228
+ overTheTop: boolean;
229
+ }
230
+ /**
231
+ * Absolute pose that brings the given pixel to the centre of frame.
232
+ *
233
+ * Composes a rotation rather than adding two scalars. The gimbal's yaw axis is
234
+ * world-vertical, so yawing while pitched sweeps a CONE: the two rotations do
235
+ * not commute, and `target = current + offset` is exact only at zero pitch or
236
+ * zero horizontal offset. Adding them cost 0.98 degrees at pitch 7.6 / yaw 31 on
237
+ * hardware, against `pitch * (1 - cos yaw)` = 1.09 predicted.
238
+ *
239
+ * The ray to the target in camera coordinates (x right, y down, z forward) is
240
+ * rotated into world coordinates by the current orientation, and the target pose
241
+ * is read back off that direction. The two axes reduce to the old additive sum
242
+ * under DIFFERENT conditions, not the same one: yaw reduces exactly to the old
243
+ * sum whenever pitch = 0, for any pixel, because yaw and pitch commute at zero
244
+ * tilt. Pitch reduces to the old sum only when u = 0 (the pixel sits on the
245
+ * vertical centre line) — composed pitch is asin(dy/n) where n includes dx, so
246
+ * away from u = 0 it differs from the old atan(dy) even when pitch is 0. That is
247
+ * what the invariant tests pin.
248
+ *
249
+ * Saturation is REPORTED, not silent: if the target lies outside the gimbal's
250
+ * range the caller has to know it landed short rather than assume the aim
251
+ * succeeded, since a silent clamp presents as "the camera aimed and missed".
252
+ *
253
+ * `current` must be where the camera actually was when the frame was captured.
254
+ * The module cannot verify that — see the spec's section 5 for what breaks it.
255
+ * Note that on Windows the pose the caller reads is FLOORED to whole degrees
256
+ * (spec section 4.2), which costs up to another degree; that is a separate
257
+ * defect in the pose source, not in this function.
258
+ */
259
+ export declare function aimAtPixel(x: number, y: number, frame: Frame, optics: Optics, current: Pose): Aim;
@@ -0,0 +1,317 @@
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: at 1920x1080 this camera has two different windows
46
+ * onto the sensor, and FRAME RATE selects between them — not the codec.
47
+ * MEASURED 2026-07-25 at one pose and one zoom: MJPEG@30 vs YUYV@30 came out at
48
+ * scale 1.00001, t (0.2, 0.0) over 2382 inliers at 0.12 px, i.e. the same field
49
+ * to within a fifth of a pixel; MJPEG@60 is a 1.214x crop of BOTH (1.21422 and
50
+ * 1.21404). An earlier revision recorded this as "MJPEG is a 1.201x crop of
51
+ * YUYV" — that comparison was MJPEG@60 against YUYV@30 and charged the codec
52
+ * for what the frame rate did.
53
+ *
54
+ * These constants describe the WIDE (30fps) field. That is what
55
+ * `obsbot_capture_snapshot` delivers: its graph negotiates MJPG 1920x1080@30,
56
+ * which the reply now states outright in `sourceFormat`.
57
+ * `obsbot_capture_preview` pins 60fps to buy smooth motion and therefore shows
58
+ * ~21% less — so preview pixels are NOT interchangeable with snapshot pixels
59
+ * for aiming.
60
+ *
61
+ * Any future measurement must state pixel format AND frame rate; neither a
62
+ * resolution nor a codec alone identifies the field.
63
+ *
64
+ * SCOPE: 16:9 capture at any resolution. A 4:3 path would need re-measuring.
65
+ */
66
+ export const WIDE_HFOV_DEG = 67;
67
+ /**
68
+ * Linear magnification of each FOV setting relative to the wide field.
69
+ *
70
+ * MEASURED 2026-07-25. These are the precise part of the pair: the ratios are
71
+ * good to ~0.05% where the anchor above is good to perhaps 0.5%.
72
+ *
73
+ * The continuous zoom writes to this same scale rather than multiplying on top
74
+ * of it — `narrow` plus zoom ratio 1.5 measures 2.509, the same as `wide` plus
75
+ * 1.5 (2.501), not 1.47 x 2.5. The discrete modes and the zoom control are two
76
+ * ways of writing to one magnification scale, which is why setting zoom ratio
77
+ * 1.0 does not reliably clear `custom`: it is the same optical state as `wide`.
78
+ */
79
+ export const FOV_MAGNIFICATION = {
80
+ wide: 1,
81
+ medium: 1.15060,
82
+ narrow: 1.47073,
83
+ };
84
+ export const HORIZONTAL_FOV_DEG = {
85
+ wide: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.wide)),
86
+ medium: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.medium)),
87
+ narrow: 2 * toDeg(Math.atan(Math.tan(toRad(WIDE_HFOV_DEG / 2)) / FOV_MAGNIFICATION.narrow)),
88
+ };
89
+ /**
90
+ * Empirical correction applied on top of the aspect-derived vertical half-angle.
91
+ * MEASURED, not geometric.
92
+ *
93
+ * Square-pixel geometry says tan(V) = tan(H) * (height/width) — 0.5625 at 16:9.
94
+ * Hardware says the vertical field is shorter than that; this factor carries the
95
+ * difference.
96
+ *
97
+ * MEASURED 2026-07-25 from the same intrinsics solve as WIDE_HFOV_DEG above.
98
+ * fy came out 1502-1520 px across every subset of six rotations, implying this
99
+ * factor at 0.957-0.967. Rounded to 0.957.
100
+ *
101
+ * This REPLACES an earlier value of 0.898, which was 7% low. That figure came
102
+ * from a measurement this project's own history recorded as inconclusive: the
103
+ * constant itself was off by about 6.2%, versus the ~4.3% effect it was trying
104
+ * to capture — roughly 1.4x the effect itself, not an order of magnitude off.
105
+ *
106
+ * The up/down asymmetry is ~1%, not the ~5% once believed: solving from the
107
+ * up-tilt alone gives 0.957 and from the down-tilt alone 0.967. One constant
108
+ * captures that comfortably. Do NOT reintroduce a two-branch vertical constant
109
+ * on the strength of the old figure. Both candidate explanations for a genuine
110
+ * asymmetry were tested and eliminated — the principal point is centred (cx, cy
111
+ * within a few px of frame centre in every solve) and radial distortion is
112
+ * negligible (k1 ~= -0.02).
113
+ *
114
+ * Verified head-to-head on hardware, same feature and same start pose with only
115
+ * this constant differing: a target at v = -0.83 left a pitch residual of -0.597
116
+ * degrees under 0.898 and +0.072 under 0.957, an 8x improvement that lands
117
+ * inside the noise.
118
+ *
119
+ * Known limit: the intrinsics fit carries 2.4-2.8 px rms, not sub-pixel, so
120
+ * something is unmodelled — most likely the entrance pupil sitting off the
121
+ * gimbal's rotation axes, which translates the lens as it turns and is
122
+ * depth-dependent. Sampling was symmetric so it should not bias fx or fy, but do
123
+ * not claim more precision than that.
124
+ *
125
+ * SCOPE: measured on the 16:9 capture path. A 4:3 path needs its own measurement.
126
+ */
127
+ export const VERTICAL_TANGENT_CORRECTION = 0.957;
128
+ /** Magnification of the wide field, and of the whole scale, at its extremes. */
129
+ export const MIN_MAGNIFICATION = 1;
130
+ export const MAX_MAGNIFICATION = 4;
131
+ /**
132
+ * Linear magnification for a UVC zoom ratio. MEASURED 2026-07-25: magnification
133
+ * is linear in the ratio, `m = 3r - 2`, holding to better than 0.05% at ratios
134
+ * 1.25, 1.5 and 2.0. So ratio 2.0 is 4x linear — carried for a long time as an
135
+ * unsourced note, now measured to four figures. See the spec's section 1.1.
136
+ */
137
+ export const magnificationFromZoomRatio = (ratio) => 3 * ratio - 2;
138
+ /** Inverse of {@link magnificationFromZoomRatio}: the ratio that yields `m`. */
139
+ export const zoomRatioFromMagnification = (m) => (m + 2) / 3;
140
+ // The tangents are the useful form for every downstream calculation, so they are
141
+ // computed once here and the degree-valued halfAngles() is a thin wrapper. Going
142
+ // through degrees would mean an atan followed immediately by a tan.
143
+ //
144
+ // This is also the ONE place every public entry point (halfAngles, pixelToOffset,
145
+ // aimAtPixel) funnels through, which is why the magnification bound is enforced
146
+ // here rather than trusted from the caller. A magnification of 0 divides out to
147
+ // tanH = Infinity, which atan silently resolves to a 90-degree half-angle; a
148
+ // negative value flips the half-angle's sign with no error; NaN propagates all
149
+ // the way through aimAtPixel's output. Before this guard MIN_MAGNIFICATION/
150
+ // MAX_MAGNIFICATION were exported but enforced nowhere — decorative constants
151
+ // that a malformed device reading (this module's callers now derive magnification
152
+ // from a reported zoomPercent) could silently violate. Callers that already
153
+ // validate their own input (resolveMagnification in src/mcp/tools.ts) should
154
+ // never actually trip this; it exists as the backstop for whichever caller
155
+ // doesn't.
156
+ const halfAngleTangents = (optics, frame) => {
157
+ if (!Number.isFinite(optics.magnification) ||
158
+ optics.magnification < MIN_MAGNIFICATION ||
159
+ optics.magnification > MAX_MAGNIFICATION) {
160
+ throw new RangeError(`optics.magnification must be finite and within [${MIN_MAGNIFICATION}, ${MAX_MAGNIFICATION}], got ${optics.magnification}`);
161
+ }
162
+ const tanH = Math.tan(toRad(WIDE_HFOV_DEG / 2)) / optics.magnification;
163
+ // Vertical starts from the horizontal half-angle scaled by the frame aspect —
164
+ // what square-pixel geometry predicts — then takes the measured correction,
165
+ // because hardware says the real vertical field is ~4.3% shorter than that.
166
+ // See VERTICAL_TANGENT_CORRECTION for the intrinsics solve over six gimbal
167
+ // rotations behind it.
168
+ return { tanH, tanV: tanH * (frame.height / frame.width) * VERTICAL_TANGENT_CORRECTION };
169
+ };
170
+ /** Effective half-angles of the visible field, in degrees, after zoom and aspect. */
171
+ export function halfAngles(optics, frame) {
172
+ const { tanH, tanV } = halfAngleTangents(optics, frame);
173
+ return { h: toDeg(Math.atan(tanH)), v: toDeg(Math.atan(tanV)) };
174
+ }
175
+ /**
176
+ * Convert a pixel in a captured frame to the yaw/pitch delta that would bring
177
+ * that point to the center of frame, treating each axis independently.
178
+ *
179
+ * PER-AXIS ONLY — this does NOT account for axis coupling. The gimbal's yaw
180
+ * axis is world-vertical, so yawing while pitched sweeps a cone; the two axes
181
+ * commute only at zero pitch or zero horizontal offset (see `aimAtPixel`,
182
+ * which composes a rotation instead and is what every tool actually points
183
+ * the camera with). Adding this function's dYaw/dPitch to a pose that is not
184
+ * level reproduces the additive bug `aimAtPixel` was fixed to remove. It is
185
+ * used only by tests now, to pin the per-axis tangent mapping in isolation
186
+ * from the coupling — do not reach for it as a general "point the camera"
187
+ * answer.
188
+ *
189
+ * A rectilinear lens maps angle through a tangent: tan(theta) = u * tan(hfov/2),
190
+ * where u is the normalized offset from center. The linear approximation is
191
+ * exact at the center and again at the edge, and wrong in between — always low,
192
+ * peaking near 1.58 degrees at u ~= 0.55 on the wide setting (WIDE_HFOV_DEG =
193
+ * 67). That error is the difference between landing on target and visibly
194
+ * hunting, so the tangent form is not optional.
195
+ *
196
+ * Signs: +yaw pans camera-LEFT and image x grows rightward, so the yaw term is
197
+ * negated. +pitch tilts DOWN and image y grows downward, so the pitch term is
198
+ * not. Both conventions are hardware-verified.
199
+ */
200
+ export function pixelToOffset(x, y, frame, optics) {
201
+ const { tanH, tanV } = halfAngleTangents(optics, frame);
202
+ const u = (2 * x) / frame.width - 1;
203
+ const v = (2 * y) / frame.height - 1;
204
+ const uEff = optics.mirrored ? -u : u;
205
+ return {
206
+ dYaw: -toDeg(Math.atan(uEff * tanH)),
207
+ dPitch: toDeg(Math.atan(v * tanV)),
208
+ };
209
+ }
210
+ /**
211
+ * Mechanical limits of the Tiny 2's gimbal, in degrees. Hardware-verified.
212
+ *
213
+ * These live here rather than in the tool layer so there is exactly one
214
+ * definition: `obsbot_gimbal_move` imports them for its own clamping. Two copies
215
+ * of a bound that must agree is a defect waiting to happen.
216
+ *
217
+ * ±150 is the mechanical yaw range on EVERY platform, and these limits are not
218
+ * platform-conditional. Measured 2026-07-25: commanded 145 reads back 145, and
219
+ * 150 reads back 149. Position feedback comes from the camera's physical
220
+ * encoder, which is a property of the hardware and does not vary by OS.
221
+ *
222
+ * Do not "fix" this to 130. `transport/linux.ts` and `transport/macos.ts` record
223
+ * a `CT_PANTILT_ABSOLUTE` range of ±468000 arcsec = ±130°, which reads like a
224
+ * conflict and was raised as one during review. It is not: that is the range the
225
+ * UVC control *advertises* — a descriptor value that under-reports the mechanism
226
+ * it describes — not where the gimbal stops. Nothing clamps to it either;
227
+ * `LinuxTransport.gimbalSet` writes `yawDeg * ARCSEC_PER_DEG` unclamped, so the
228
+ * arcsec figure lives only in comments. See the spec's §8.
229
+ */
230
+ export const GIMBAL_YAW_LIMIT_DEG = 150;
231
+ export const GIMBAL_PITCH_LIMIT_DEG = 90;
232
+ const clampTo = (value, limit) => Math.min(limit, Math.max(-limit, value));
233
+ /**
234
+ * Absolute pose that brings the given pixel to the centre of frame.
235
+ *
236
+ * Composes a rotation rather than adding two scalars. The gimbal's yaw axis is
237
+ * world-vertical, so yawing while pitched sweeps a CONE: the two rotations do
238
+ * not commute, and `target = current + offset` is exact only at zero pitch or
239
+ * zero horizontal offset. Adding them cost 0.98 degrees at pitch 7.6 / yaw 31 on
240
+ * hardware, against `pitch * (1 - cos yaw)` = 1.09 predicted.
241
+ *
242
+ * The ray to the target in camera coordinates (x right, y down, z forward) is
243
+ * rotated into world coordinates by the current orientation, and the target pose
244
+ * is read back off that direction. The two axes reduce to the old additive sum
245
+ * under DIFFERENT conditions, not the same one: yaw reduces exactly to the old
246
+ * sum whenever pitch = 0, for any pixel, because yaw and pitch commute at zero
247
+ * tilt. Pitch reduces to the old sum only when u = 0 (the pixel sits on the
248
+ * vertical centre line) — composed pitch is asin(dy/n) where n includes dx, so
249
+ * away from u = 0 it differs from the old atan(dy) even when pitch is 0. That is
250
+ * what the invariant tests pin.
251
+ *
252
+ * Saturation is REPORTED, not silent: if the target lies outside the gimbal's
253
+ * range the caller has to know it landed short rather than assume the aim
254
+ * succeeded, since a silent clamp presents as "the camera aimed and missed".
255
+ *
256
+ * `current` must be where the camera actually was when the frame was captured.
257
+ * The module cannot verify that — see the spec's section 5 for what breaks it.
258
+ * Note that on Windows the pose the caller reads is FLOORED to whole degrees
259
+ * (spec section 4.2), which costs up to another degree; that is a separate
260
+ * defect in the pose source, not in this function.
261
+ */
262
+ export function aimAtPixel(x, y, frame, optics, current) {
263
+ const { tanH, tanV } = halfAngleTangents(optics, frame);
264
+ const u = (2 * x) / frame.width - 1;
265
+ const v = (2 * y) / frame.height - 1;
266
+ const uEff = optics.mirrored ? -u : u;
267
+ // Ray to the target, in camera coordinates: x right, y down, z forward.
268
+ const dx = uEff * tanH;
269
+ const dy = v * tanV;
270
+ const dz = 1;
271
+ const n = Math.sqrt(dx * dx + dy * dy + dz * dz);
272
+ const cy = Math.cos(toRad(current.yaw));
273
+ const sy = Math.sin(toRad(current.yaw));
274
+ const cp = Math.cos(toRad(current.pitch));
275
+ const sp = Math.sin(toRad(current.pitch));
276
+ // Pitch first, about the camera's own x-axis (+pitch tilts DOWN)...
277
+ const px = dx / n;
278
+ const py = (dy * cp + dz * sp) / n;
279
+ const pz = (-dy * sp + dz * cp) / n;
280
+ // ...then yaw, about the world vertical (+yaw pans camera-LEFT).
281
+ const wx = px * cy - pz * sy;
282
+ const wy = py;
283
+ const wz = px * sy + pz * cy;
284
+ const rawPitch = toDeg(Math.asin(Math.max(-1, Math.min(1, wy))));
285
+ // atan2 returns [-180, 180] — -180 IS included, and is exactly the case the
286
+ // following paragraph handles. Pick the representative nearest the current
287
+ // yaw so a target past +150 reads as +183 rather than -177. Without this a
288
+ // saturating aim clamps to the WRONG END of the range.
289
+ //
290
+ // This is a modulo wrap rather than `Math.round((current.yaw - rawYaw) /
291
+ // 360)`: at an exact 180-degree difference — dead behind the camera, which
292
+ // happens for real at u = 0 aiming past vertical — that rounds ties toward
293
+ // +Infinity regardless of sign (JS's Math.round(0.5) is 1, not 0), which
294
+ // silently flips -180 to +180 and clamps on the wrong end. The wrap below
295
+ // always resolves an exact tie to the lower edge of the window instead.
296
+ const rawYawAtan2 = toDeg(Math.atan2(-wx, wz));
297
+ const rawYaw = current.yaw + (((rawYawAtan2 - current.yaw + 180) % 360 + 360) % 360 - 180);
298
+ const yaw = clampTo(rawYaw, GIMBAL_YAW_LIMIT_DEG);
299
+ const pitch = clampTo(rawPitch, GIMBAL_PITCH_LIMIT_DEG);
300
+ // Over the top: the target ray's horizontal direction points opposite the
301
+ // camera's current heading. (hx, hz) is that heading; (wx, wz) is the target
302
+ // direction, both already computed above. A negative dot product means the
303
+ // only way to face the target while keeping the image upright is to go past
304
+ // vertical — clamping the resulting yaw to the nearest limit would slew the
305
+ // camera toward the OPPOSITE side of the room, not toward the target, so the
306
+ // caller must refuse this case rather than clamp-and-move it.
307
+ const hx = -sy;
308
+ const hz = cy;
309
+ const overTheTop = hx * wx + hz * wz < 0;
310
+ return {
311
+ target: { yaw, pitch },
312
+ offset: { dYaw: rawYaw - current.yaw, dPitch: rawPitch - current.pitch },
313
+ clamped: yaw !== rawYaw || pitch !== rawPitch,
314
+ overTheTop,
315
+ };
316
+ }
317
+ //# 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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"}
@@ -15,6 +15,7 @@ export type ReadyResult = {
15
15
  ok: true;
16
16
  transport: ObsbotTransport;
17
17
  reconnected: boolean;
18
+ woke: boolean;
18
19
  } | {
19
20
  ok: false;
20
21
  reason: "unreachable" | "wake-timeout";
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
@@ -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":"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"}
@@ -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.4.1" }, { capabilities: { tools: {} } });
74
+ { name: "obsbot-mcp", version: "0.6.0" }, { capabilities: { tools: {} } });
75
75
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
76
76
  tools: tools.map((tool) => ({
77
77
  name: tool.name,