obsbot-mcp 0.5.0 → 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.
package/dist/mcp/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
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";
2
+ import { encodeSetRunStatus, zoomRatioToUnits, encodeAiTrackSpeed, encodeAiTracking, encodeAiMode, encodeFaceAe, encodeVendorProbe, encodeVendorGet, encodeZoomWithSpeed, encodeFaceFocus, encodeSetExposure, decodeStatus, encodeFov, encodeHdr, percentToRange, rangeToPercent, 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, FOV_MAGNIFICATION, magnificationFromZoomRatio, zoomRatioFromMagnification, MIN_MAGNIFICATION, MAX_MAGNIFICATION, } from "../geometry/aim.js";
4
4
  import { verifyFraming } from "./framing.js";
5
5
  import { parseFrame } from "../codec/frame.js";
6
6
  import { OP_BY_NAME } from "../codec/opcodes.js";
@@ -9,6 +9,61 @@ import { CameraBusyError } from "../transport/transport.js";
9
9
  import { ensureReady, msg } from "./ready.js";
10
10
  import { CaptureError } from "../capture/manager.js";
11
11
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
12
+ /**
13
+ * The camera's total magnification relative to wide, from its reported state.
14
+ *
15
+ * A discrete FOV mode and a continuous zoom are two ways of writing to one
16
+ * scale, so this returns one number either way. Fails (`ok: false`) for
17
+ * anything that cannot be trusted:
18
+ * - `reason: "unknown-fov"` — the status byte didn't decode, a state to
19
+ * refuse on rather than guess at.
20
+ * - `reason: "implausible-zoom"` — a `"custom"` mode whose derived
21
+ * magnification falls outside the camera's known range
22
+ * [MIN_MAGNIFICATION, MAX_MAGNIFICATION] — a corrupt or implausible
23
+ * `zoomPercent` reading (e.g. a garbled status byte reporting
24
+ * zoomPercent > 100). Callers should refuse rather than pass this through:
25
+ * the geometry module's own guard (halfAngleTangents in src/geometry/aim.ts)
26
+ * would otherwise be the only thing standing between a bad reading and a
27
+ * silently wrong — or NaN/Infinity — aim.
28
+ */
29
+ export function resolveMagnification(status) {
30
+ if (status.fovMode === "unknown")
31
+ return { ok: false, reason: "unknown-fov" };
32
+ if (status.fovMode === "custom") {
33
+ const m = magnificationFromZoomRatio(1 + status.zoomPercent / 100);
34
+ if (!Number.isFinite(m) || m < MIN_MAGNIFICATION || m > MAX_MAGNIFICATION) {
35
+ return { ok: false, reason: "implausible-zoom", zoomPercent: status.zoomPercent };
36
+ }
37
+ return { ok: true, magnification: m };
38
+ }
39
+ return { ok: true, magnification: FOV_MAGNIFICATION[status.fovMode] };
40
+ }
41
+ /**
42
+ * Refuse a frameWidth/frameHeight pair that isn't 16:9. Shared by
43
+ * `obsbot_aim_at_pixel` and `obsbot_zoom_to_fit` — both take frame dimensions
44
+ * from the same `obsbot_capture_snapshot` result, and both are equally exposed
45
+ * to a caller that swaps width and height.
46
+ *
47
+ * 16:9 is preserved by the capture path at every resolution (verified at
48
+ * 256x144, 1280x720, 1920x1080), so a non-16:9 pair did not come from that path
49
+ * as-is — it was transposed (width/height swapped) or came from somewhere else
50
+ * entirely, since passing the values through unchanged always yields 16:9. For
51
+ * `obsbot_zoom_to_fit` a transposed pair is worse than a bad aim: it also flips
52
+ * which axis `Math.min(frameWidth/width, frameHeight/height)` selects, so the
53
+ * camera would frame the wrong region at the wrong zoom while still reporting
54
+ * `ok: true`.
55
+ */
56
+ function refuseIfNot169(frameWidth, frameHeight) {
57
+ if (Math.abs(frameWidth / frameHeight - 16 / 9) > 0.02) {
58
+ return {
59
+ ok: false,
60
+ error: `frame ${frameWidth}x${frameHeight} is not 16:9, but obsbot_capture_snapshot always ` +
61
+ `returns 16:9 frames. This looks like frameWidth/frameHeight were transposed, or came ` +
62
+ `from something other than that tool's result.`,
63
+ };
64
+ }
65
+ return null;
66
+ }
12
67
  // Some MCP clients serialize numbers and booleans as strings when the advertised
13
68
  // inputSchema lacks type info. We now advertise a proper JSON Schema (see
14
69
  // mcp/server.ts), but also accept string-encoded values defensively so the tools
@@ -114,11 +169,32 @@ const imageExposureManualSchema = withCamera({
114
169
  level: num().pipe(z.number().min(0).max(100)).default(50),
115
170
  });
116
171
  const gimbalPositionSchema = withCamera({});
172
+ const captureSourceEnum = z.enum(["device", "virtual", "ndi"]);
173
+ // `source` is a DECLARATION, not a selector: these tools never fetch a frame, they
174
+ // are handed one. It exists because the frame's origin is otherwise unknowable —
175
+ // see frameSourceNote — and it defaults to `device`, which is what every caller
176
+ // got implicitly before.
117
177
  const aimAtPixelSchema = withCamera({
118
178
  x: num().pipe(z.number().finite()),
119
179
  y: num().pipe(z.number().finite()),
120
180
  frameWidth: num().pipe(z.number().finite().min(1)),
121
181
  frameHeight: num().pipe(z.number().finite().min(1)),
182
+ source: captureSourceEnum.default("device"),
183
+ });
184
+ // width/height/x/y are deliberately unconstrained beyond "finite": a negative
185
+ // width or an off-frame region is a valid INPUT (the schema's job), just not a
186
+ // valid REGION (the handler's job, so it can return a structured ok:false with
187
+ // the reason instead of a schema parse throw — matching how aimAtPixelSchema's
188
+ // own x/y-in-frame check works one level up, in the handler, not the schema).
189
+ const zoomToFitSchema = withCamera({
190
+ x: num().pipe(z.number().finite()),
191
+ y: num().pipe(z.number().finite()),
192
+ width: num().pipe(z.number().finite()),
193
+ height: num().pipe(z.number().finite()),
194
+ frameWidth: num().pipe(z.number().finite().min(1)),
195
+ frameHeight: num().pipe(z.number().finite().min(1)),
196
+ margin: num().pipe(z.number().finite().min(0)).default(0.1),
197
+ source: captureSourceEnum.default("device"),
122
198
  });
123
199
  const presetListSchema = withCamera({});
124
200
  const presetSaveSchema = withCamera({
@@ -156,10 +232,15 @@ const PRESET_NAME_MAX = 40;
156
232
  const snapshotSchema = withCamera({
157
233
  resolution: num().pipe(z.number().min(256).max(1920)).default(640),
158
234
  quality: num().pipe(z.number().min(1).max(100)).default(80),
159
- settleMs: num().pipe(z.number().min(0).max(5000)).default(600),
235
+ // Ceiling raised from 5000 on 2026-07-25. A source that connects lazily needs
236
+ // longer than a UVC camera does to hand over its first frame — NDI Webcam Input
237
+ // measured 4-5s — and 5000 sat right on that threshold, so the one value that
238
+ // worked was also the largest the schema allowed. The helper now budgets its
239
+ // own grace period on top of this, so callers rarely need to raise it at all;
240
+ // the headroom is for the genuinely slow case rather than the normal one.
241
+ settleMs: num().pipe(z.number().min(0).max(15000)).default(600),
160
242
  source: z.enum(["device", "virtual", "ndi"]).default("device"),
161
243
  });
162
- const captureSourceEnum = z.enum(["device", "virtual", "ndi"]);
163
244
  const recordStartSchema = z.object({
164
245
  durationSec: num().pipe(z.number().positive()).optional(),
165
246
  audio: bool().default(true),
@@ -275,6 +356,140 @@ const napMs = (ms) => new Promise((r) => setTimeout(r, ms));
275
356
  // reflects the last commanded pose) without implying precision the readout
276
357
  // doesn't have.
277
358
  const round2 = (deg) => Math.round(deg * 100) / 100;
359
+ // motionPollMs is the sensitivity knob for readSteadyStatus: zoomPercent is an
360
+ // integer, so a beat of B ms detects any ramp travelling faster than 1000/B
361
+ // percentage points per second. 80ms => 12.5 pt/s, against a measured UVC ramp of
362
+ // ~42 pt/s (2026-07-25: a full ratio 1->2 sweep took 2397ms) — comfortable margin,
363
+ // while costing every aim only one extra status read and 80ms.
364
+ const ZOOM_SETTLE_DEFAULTS = {
365
+ pollMs: 100,
366
+ timeoutMs: 3000,
367
+ motionPollMs: 80,
368
+ };
369
+ // zoomPercent tracks the zoom's actual travel, not the commanded value, which is
370
+ // what makes it a usable arrival signal — a status read taken right after
371
+ // commanding the write can catch it mid-ramp (observed: commanding ratio 1.5
372
+ // read back zoomPercent 33 in transit before settling at 50). zoomPercent is on
373
+ // the SAME 0-100 scale resolveMagnification reads it on (ratio = 1 + pct/100),
374
+ // so the target is expressed on that scale too, not the raw device units
375
+ // zoomSet takes (those come from zoomRange(), which can differ per device).
376
+ const ZOOM_SETTLE_TOLERANCE_PCT = 1;
377
+ /**
378
+ * Poll the status block until `zoomPercent` shows the zoom has actually arrived
379
+ * at `targetRatio`, or give up after the bound. Returns false rather than
380
+ * throwing on timeout: a camera moving slower than expected (or a hardware zoom
381
+ * that overshoots/undershoots slightly) is information the caller needs, not a
382
+ * failure — the caller decides what to do with `settled:false`, this function
383
+ * just refuses to lie about it.
384
+ *
385
+ * A transient read failure inside the loop is swallowed the same way: by the
386
+ * time this runs, the gimbal move and the zoom write have already happened, so
387
+ * throwing here would lose the `target`/`ratio` the caller needs to know what
388
+ * the camera just did. A read that throws is treated as "not settled yet" —
389
+ * the loop keeps polling until the deadline and resolves `false` rather than
390
+ * rejecting, so the tool still returns its normal result.
391
+ */
392
+ async function waitForZoomSettle(t, targetRatio, opts = {}) {
393
+ const { pollMs, timeoutMs } = { ...ZOOM_SETTLE_DEFAULTS, ...opts };
394
+ const targetPct = (targetRatio - 1) * 100;
395
+ const deadline = Date.now() + timeoutMs;
396
+ for (;;) {
397
+ try {
398
+ const status = decodeStatus(await t.recvStatus());
399
+ if (Math.abs(status.zoomPercent - targetPct) <= ZOOM_SETTLE_TOLERANCE_PCT)
400
+ return true;
401
+ }
402
+ catch {
403
+ // Transient read failure — treat as not-yet-settled rather than propagating.
404
+ }
405
+ if (Date.now() >= deadline)
406
+ return false;
407
+ await napMs(pollMs);
408
+ }
409
+ }
410
+ async function readSteadyStatus(t, opts = {}) {
411
+ const { motionPollMs } = { ...ZOOM_SETTLE_DEFAULTS, ...opts };
412
+ const firstPct = decodeStatus(await t.recvStatus()).zoomPercent;
413
+ await napMs(motionPollMs);
414
+ // The SECOND read is the one handed back: it is the fresher of the two, and by
415
+ // the time it is returned it has been confirmed equal to the first anyway.
416
+ const block = await t.recvStatus();
417
+ const status = decodeStatus(block);
418
+ if (status.zoomPercent !== firstPct) {
419
+ return { ok: false, fromPct: firstPct, toPct: status.zoomPercent };
420
+ }
421
+ return { ok: true, block, status };
422
+ }
423
+ /** The refusal both aiming tools give when {@link readSteadyStatus} sees travel. */
424
+ const zoomMovingError = (r, verb) => `the zoom is still moving (zoomPercent read ${r.fromPct} then ${r.toPct}), so the frame you ` +
425
+ `measured was captured at a magnification the camera has already left — ${verb} from it would ` +
426
+ `be wrong in proportion. Wait for the zoom to finish, take a fresh snapshot, and try again.`;
427
+ /**
428
+ * Read the focus control for {@link obsbot_status}.
429
+ *
430
+ * Focus is a standard UVC control, not a field of the 60-byte vendor status
431
+ * block, so reporting it costs two extra reads. Worth it: obsbot_focus_auto and
432
+ * obsbot_focus_manual were write-only, and answering "is it still in autofocus?"
433
+ * previously meant going underneath the tool surface to the helper's camctrl_get
434
+ * by hand.
435
+ *
436
+ * NEVER THROWS. The status block is what this tool is for, and focus is an
437
+ * addition to it — trading awake/aiMode/zoomPercent for a failed focus query
438
+ * would be a bad deal on any device or platform that cannot answer it. A failure
439
+ * reports mode "unknown" and omits the position, which is also what an
440
+ * unrecognised flags value gets: the same refuse-to-guess rule the FOV and
441
+ * AI-mode decodes already follow.
442
+ *
443
+ * focusPosition is reported ONLY in manual mode, where it is the setpoint and
444
+ * round-trips exactly (write 25, read 25). Under autofocus the device does not
445
+ * expose the motor: MEASURED 2026-07-25, the value stayed pinned at the last
446
+ * written position across a 40-degree pan, a zoom from 3.34x to 1x, and 9s of
447
+ * settling — a scene change that must have re-focused the lens. It is the same
448
+ * setpoint-echo this camera does for pan/tilt. Reporting that number under
449
+ * autofocus would look like a live focus distance while being a stale write, so
450
+ * the field is omitted instead and the caller can tell the difference.
451
+ *
452
+ * In manual mode the position is normalised onto the same 0-100 scale
453
+ * obsbot_focus_manual accepts, so the control reads back in the units it was
454
+ * written in.
455
+ */
456
+ async function readFocus(t) {
457
+ try {
458
+ const { value, flags } = await t.camCtrlGet(CAMERA_CONTROL_FOCUS);
459
+ const focusMode = flags === UVC_FLAG_AUTO ? "auto" : flags === UVC_FLAG_MANUAL ? "manual" : "unknown";
460
+ if (focusMode !== "manual")
461
+ return { focusMode };
462
+ const { min, max } = await t.camCtrlRange(CAMERA_CONTROL_FOCUS);
463
+ return { focusMode, focusPosition: rangeToPercent(value, min, max) };
464
+ }
465
+ catch {
466
+ return { focusMode: "unknown" };
467
+ }
468
+ }
469
+ /**
470
+ * What a non-device frame declaration commits the caller to.
471
+ *
472
+ * obsbot_aim_at_pixel and obsbot_zoom_to_fit compute an angle from the CAMERA's
473
+ * own field of view and magnification, but they only ever receive pixels — there
474
+ * is nothing in x/y/frameWidth/frameHeight from which the frame's origin could be
475
+ * inferred. A frame that OBSBOT Center or OBS has rescaled, cropped or letterboxed
476
+ * therefore yields a confidently wrong aim, reported as a clean success.
477
+ *
478
+ * A blanket refusal would be wrong: a feed configured as a true pass-through aims
479
+ * correctly. MEASURED 2026-07-25 against an OBS -> NDI chain, a dedicated
480
+ * (uncomposited) output at 1080p30 matched the camera's own frame to fill 1.0000
481
+ * and scale 1.03 +/- 0.03, and obsbot_zoom_to_fit framed a target through it
482
+ * correctly. The SAME chain via OBS's canvas measured 0.900 scale with a 96px
483
+ * offset -- wrong by 11% plus a fixed bias -- and looked identical in the reply.
484
+ *
485
+ * So the caller declares the feed and this states the assumption back, where it
486
+ * lands in the transcript rather than in a README nobody re-reads mid-task.
487
+ */
488
+ const frameSourceNote = (source) => `this aim was computed from a '${source}' frame, and is only correct if that feed is an ` +
489
+ `unmodified pass-through of the camera (no rescale, crop, letterbox or reframing) at the same ` +
490
+ `field of view — 1080p60 is a 1.214x crop of 1080p30 on this camera, and a compositor's canvas ` +
491
+ `scaling is invisible in the picture. Verify once by commanding a known gimbal rotation and ` +
492
+ `checking features move by the predicted number of pixels; a 'device' frame needs no such check.`;
278
493
  const allEmpty = (slots) => slots.every((s) => !s.occupied);
279
494
  /**
280
495
  * Read the preset slots with bounded retry, and confirm the one verdict whose error
@@ -316,7 +531,7 @@ async function readPresetSlots(t, gate, opts = {}) {
316
531
  }
317
532
  throw lastErr;
318
533
  }
319
- export function createTools(mgr, capture, debug = false, presetRead = {}) {
534
+ export function createTools(mgr, capture, debug = false, presetRead = {}, zoomSettle = {}) {
320
535
  // Per-camera resolution derived from the manager. Every camera-addressing tool
321
536
  // takes an optional `camera` selector (the serial); it threads through here so
322
537
  // the transport, reconnect controller and readiness gate all target that one
@@ -467,7 +682,11 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
467
682
  name: "obsbot_zoom_uvc",
468
683
  description: "Standard UVC zoom: set an absolute zoom ratio, clamped to [1.0, 2.0]. Snaps to the " +
469
684
  "requested target exactly (unlike obsbot_zoom_vendor, whose ratio scale differs and " +
470
- "may not land exactly where asked).",
685
+ "may not land exactly where asked). Waits for the zoom to actually arrive and returns " +
686
+ "{ settled }: the ramp is not instant (a full 1.0->2.0 sweep takes about 2.4s), and " +
687
+ "obsbot_aim_at_pixel and obsbot_zoom_to_fit both refuse while it is in flight, so this " +
688
+ "returning early would just move the failure downstream. settled:false means the zoom " +
689
+ "had not arrived within the timeout — the command was still sent.",
471
690
  schema: zoomAbsoluteSchema,
472
691
  handler: async (args) => {
473
692
  const parsed = zoomAbsoluteSchema.parse(args);
@@ -475,7 +694,14 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
475
694
  const t = await getTransport(parsed.camera);
476
695
  const { min, max } = await t.zoomRange();
477
696
  await t.zoomSet(zoomRatioToUnits(ratio, min, max));
478
- return { ok: true, ratio };
697
+ // Deliberately NOT done for obsbot_zoom_vendor. waitForZoomSettle needs the
698
+ // target on the zoomPercent scale, and that tool's ratio scale is a different
699
+ // one that "may not land exactly where asked" — so it would report
700
+ // settled:false on a perfectly good zoom. The alternative signal, "zoomPercent
701
+ // stopped changing", reads as stopped during the ~140ms before the ramp starts
702
+ // moving, which would be a worse lie than saying nothing.
703
+ const settled = await waitForZoomSettle(t, ratio, zoomSettle);
704
+ return { ok: true, ratio, settled };
479
705
  },
480
706
  },
481
707
  {
@@ -562,12 +788,18 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
562
788
  {
563
789
  name: "obsbot_status",
564
790
  description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed, " +
565
- "fovMode, zoomPercent }: " +
791
+ "fovMode, zoomPercent, focusMode, focusPosition }: " +
566
792
  "faceAe is whether auto-exposure is metering for a detected face; " +
567
793
  "aiMode is the current AI framing (no-tracking|normal|upper-body|close-up|headless|" +
568
794
  "lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown; " +
569
795
  "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. " +
796
+ "a continuous zoom overrode the discrete modes; zoomPercent is the zoom position, 0-100; " +
797
+ "focusMode is auto|manual|unknown. focusPosition is present ONLY in manual mode, on the " +
798
+ "same 0-100 scale obsbot_focus_manual takes: under autofocus this camera does not expose " +
799
+ "the motor, it echoes the last written value, so reporting it would look like a live " +
800
+ "focus distance while being stale. Focus is a standard UVC control rather than a field " +
801
+ "of the status block, so it costs an extra read; a device that cannot answer it reports " +
802
+ "focusMode unknown rather than failing the whole read. " +
571
803
  "Under --debug the result also carries `raw`: the full 60-byte status block as hex " +
572
804
  "(for reverse-engineering undecoded offsets).",
573
805
  schema: getStatusSchema,
@@ -576,7 +808,12 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
576
808
  const t = await getTransport(camera);
577
809
  try {
578
810
  const block = await t.recvStatus();
579
- return { ok: true, ...decodeStatus(block), ...(debug ? { raw: block.toString("hex") } : {}) };
811
+ return {
812
+ ok: true,
813
+ ...decodeStatus(block),
814
+ ...(await readFocus(t)),
815
+ ...(debug ? { raw: block.toString("hex") } : {}),
816
+ };
580
817
  }
581
818
  catch (e) {
582
819
  return { ok: false, error: `could not read camera status: ${e.message}` };
@@ -734,12 +971,17 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
734
971
  description: "Point the camera at a specific pixel in a frame you just captured. Give the pixel's x/y " +
735
972
  "and the frameWidth/frameHeight from THE SAME obsbot_capture_snapshot result — mixing a " +
736
973
  "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 " +
974
+ "detected. `source` DECLARES which feed the frame came from (default device); it cannot be " +
975
+ "inferred from the pixels. A virtual or ndi frame is accepted, but only aims correctly if " +
976
+ "that feed is an unmodified pass-through of the camera — a compositor's rescale or " +
977
+ "letterbox is invisible in the picture and silently wrong here — so a non-device " +
978
+ "declaration comes back with that assumption stated. Takes no " +
979
+ "field-of-view or zoom argument: it reads the camera's magnification from its reported " +
980
+ "state, a discrete FOV mode or a continuous zoom alike, so it works at any zoom. Refuses " +
981
+ "when AI tracking is active (tracking moves the gimbal itself and would fight the aim), " +
982
+ "when the FOV mode can't be decoded, or when a corrupt zoom reading would resolve to an " +
983
+ "implausible magnification, so it never aims on an assumption it cannot check. If the " +
984
+ "camera was asleep, waking it moves the gimbal and invalidates the " +
743
985
  "frame you measured, so the call refuses instead of aiming on stale geometry — take a fresh " +
744
986
  "snapshot and retry. Returns clamped:true if the target was outside the gimbal's range; the " +
745
987
  "camera still moves, to the nearest reachable pose. Refuses (ok:false) instead of moving when " +
@@ -748,20 +990,10 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
748
990
  "target; tilt toward the pixel first, then re-aim.",
749
991
  schema: aimAtPixelSchema,
750
992
  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
- }
993
+ const { x, y, frameWidth, frameHeight, camera, source } = aimAtPixelSchema.parse(args);
994
+ const aspectRefusal = refuseIfNot169(frameWidth, frameHeight);
995
+ if (aspectRefusal)
996
+ return aspectRefusal;
765
997
  if (x < 0 || x > frameWidth || y < 0 || y > frameHeight) {
766
998
  return { ok: false, error: `pixel (${x},${y}) is outside the ${frameWidth}x${frameHeight} frame` };
767
999
  }
@@ -780,8 +1012,10 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
780
1012
  "longer matches where the camera is pointing. Take a fresh snapshot and aim again.",
781
1013
  };
782
1014
  }
783
- const block = await t.recvStatus();
784
- const status = decodeStatus(block);
1015
+ const steady = await readSteadyStatus(t, zoomSettle);
1016
+ if (!steady.ok)
1017
+ return { ok: false, error: zoomMovingError(steady, "aiming") };
1018
+ const { block, status } = steady;
785
1019
  if (status.aiMode === "unknown") {
786
1020
  return {
787
1021
  ok: false,
@@ -796,34 +1030,44 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
796
1030
  `fight the aim. Disable it with obsbot_ai_track {enabled:false} first.`,
797
1031
  };
798
1032
  }
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.
1033
+ // One function decides whether the camera's reported state resolves to a
1034
+ // usable magnification — discrete mode or continuous zoom alike — so there
1035
+ // is exactly one place that decides to refuse rather than guess, AND
1036
+ // exactly one place that decides WHY (see MagnificationResult).
1037
+ const resolved = resolveMagnification(status);
1038
+ if (!resolved.ok) {
1039
+ if (resolved.reason === "unknown-fov") {
1040
+ // block[0x11] is STATUS_OFF_FOV_MODE (src/codec/commands.ts) — same offset
1041
+ // decodeStatus reads to produce fovMode, surfaced raw since "unknown" means
1042
+ // it didn't match any known value.
1043
+ return {
1044
+ ok: false,
1045
+ error: `could not read the camera's FOV mode (raw byte 0x${block[0x11].toString(16).padStart(2, "0")}); ` +
1046
+ `refusing to guess it. Set a known mode with obsbot_image_fov (e.g. {fov:"wide"}) and retry.`,
1047
+ };
1048
+ }
1049
+ // reason "implausible-zoom": fovMode is "custom" but the zoom it derives
1050
+ // from is implausible — e.g. zoomPercent far outside 0-100 — so the
1051
+ // resulting magnification falls outside the camera's known range. That is
1052
+ // a corrupt reading, not a real optical state; refuse rather than aim
1053
+ // from a bad number.
814
1054
  return {
815
1055
  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.`,
1056
+ error: `the camera's zoom reading (zoomPercent ${resolved.zoomPercent}) resolves to a ` +
1057
+ `magnification outside the camera's known range [${MIN_MAGNIFICATION}, ` +
1058
+ `${MAX_MAGNIFICATION}]; refusing rather than aim from what looks like a corrupt ` +
1059
+ `status read. Retry, or set a known mode with obsbot_image_fov.`,
818
1060
  };
819
1061
  }
1062
+ const magnification = resolved.magnification;
820
1063
  // Same read path as obsbot_gimbal_position: UVC pan is degrees with our
821
1064
  // yaw sign; UVC tilt is degrees but positive = up, so negate it.
822
1065
  const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
823
1066
  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 });
1067
+ // resolveMagnification already folded fovMode and zoomPercent into one
1068
+ // number — a discrete mode's inherent crop and a continuous zoom are two
1069
+ // ways of writing to the SAME scale, so there is nothing left to combine.
1070
+ const aim = aimAtPixel(x, y, { width: frameWidth, height: frameHeight }, { magnification }, { yaw, pitch });
827
1071
  // Over the top is not an ordinary clamp: the target ray points behind
828
1072
  // the camera's current heading, so the nearest reachable yaw is on the
829
1073
  // FAR side of the gimbal's range, not near the target. Moving there
@@ -847,6 +1091,158 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
847
1091
  clamped: aim.clamped,
848
1092
  fovMode: status.fovMode,
849
1093
  current: { yaw, pitch },
1094
+ source,
1095
+ ...(source === "device" ? {} : { note: frameSourceNote(source) }),
1096
+ ...(ready.reconnected ? { reconnected: true } : {}),
1097
+ };
1098
+ },
1099
+ },
1100
+ {
1101
+ name: "obsbot_zoom_to_fit",
1102
+ description: "Frame a region of a frame you just captured: centre the gimbal on it and zoom so the " +
1103
+ "region fills the frame. Give x/y/width/height of the region plus the frameWidth/frameHeight " +
1104
+ "from THE SAME obsbot_capture_snapshot result — mixing a region from one frame with " +
1105
+ "dimensions from another frames the wrong place and cannot be detected. Must come from a " +
1106
+ "snapshot, and takes the same `source` declaration as obsbot_aim_at_pixel. `margin` (default 0.1) " +
1107
+ "backs the zoom off by that fraction so the region isn't framed edge-to-edge; the tighter of " +
1108
+ "the region's two axes decides the zoom, so the WHOLE region stays visible rather than being " +
1109
+ "cropped on one side. Moves the gimbal BEFORE zooming, since zooming first can push the " +
1110
+ "region's centre out of frame. Refuses on the same conditions as obsbot_aim_at_pixel: AI " +
1111
+ "tracking active, the camera was asleep (waking it moves the gimbal and invalidates the " +
1112
+ "frame), the FOV mode can't be decoded, a corrupt zoom reading, or the region's centre lying " +
1113
+ "past vertical from the current pose, or a frame that isn't 16:9 (obsbot_capture_snapshot " +
1114
+ "always returns 16:9; a non-16:9 pair looks transposed). Also refuses a region that isn't " +
1115
+ "within the frame (edges included), or has non-positive width/height. The requested zoom is " +
1116
+ "clamped to the camera's [1x, 4x] magnification range and reported via `clamped`; a partial " +
1117
+ "fit still moves and zooms to the limit. Zoom ramps rather than jumping, so the tool polls " +
1118
+ "the status block for up to 3s waiting for it to arrive and returns `settled:false` (not an " +
1119
+ "error) if it didn't — " +
1120
+ "a frame captured mid-ramp is at an unknown magnification, so check `settled` before trusting " +
1121
+ "a follow-up snapshot.",
1122
+ schema: zoomToFitSchema,
1123
+ handler: async (args) => {
1124
+ const { x, y, width, height, frameWidth, frameHeight, margin, camera, source } = zoomToFitSchema.parse(args);
1125
+ // Pure input validation first, no I/O — same placement as obsbot_aim_at_pixel's
1126
+ // own frame/pixel checks, and the same order: the aspect check runs before
1127
+ // the region-bounds check, matching obsbot_aim_at_pixel.
1128
+ const aspectRefusal = refuseIfNot169(frameWidth, frameHeight);
1129
+ if (aspectRefusal)
1130
+ return aspectRefusal;
1131
+ // The region must lie within the frame, edges included: a region touching
1132
+ // the frame's own edges is valid (a region that already IS the full frame
1133
+ // must pass — the full-frame fit test depends on it), only crossing them
1134
+ // is refused.
1135
+ if (width <= 0 || height <= 0 ||
1136
+ x < 0 || y < 0 ||
1137
+ x + width > frameWidth || y + height > frameHeight) {
1138
+ return {
1139
+ ok: false,
1140
+ error: `region (${x},${y}) ${width}x${height} is not inside the ${frameWidth}x${frameHeight} ` +
1141
+ `frame, or has non-positive width/height.`,
1142
+ };
1143
+ }
1144
+ // The gate wakes a sleeping camera and waits for it to settle. If it had to
1145
+ // wake the camera, the gimbal just moved out from under the frame the caller
1146
+ // measured — refuse below rather than frame a region that no longer means
1147
+ // what it did (same reasoning as obsbot_aim_at_pixel).
1148
+ const ready = await gate(camera);
1149
+ if (!ready.ok)
1150
+ return ready;
1151
+ const t = ready.transport;
1152
+ if (ready.woke) {
1153
+ return {
1154
+ ok: false,
1155
+ error: "the camera was asleep and waking it moved the gimbal, so the frame you measured no " +
1156
+ "longer matches where the camera is pointing. Take a fresh snapshot and try again.",
1157
+ };
1158
+ }
1159
+ const steady = await readSteadyStatus(t, zoomSettle);
1160
+ if (!steady.ok)
1161
+ return { ok: false, error: zoomMovingError(steady, "framing") };
1162
+ const { block, status } = steady;
1163
+ if (status.aiMode === "unknown") {
1164
+ return {
1165
+ ok: false,
1166
+ error: "could not read the camera's AI-tracking mode (the status block didn't decode this " +
1167
+ "read — this can happen during a brief mode-switch transient); retry.",
1168
+ };
1169
+ }
1170
+ if (status.aiMode !== "no-tracking") {
1171
+ return {
1172
+ ok: false,
1173
+ error: `AI tracking is active (${status.aiMode}); it moves the gimbal itself and would ` +
1174
+ `fight the framing. Disable it with obsbot_ai_track {enabled:false} first.`,
1175
+ };
1176
+ }
1177
+ const resolved = resolveMagnification(status);
1178
+ if (!resolved.ok) {
1179
+ if (resolved.reason === "unknown-fov") {
1180
+ return {
1181
+ ok: false,
1182
+ error: `could not read the camera's FOV mode (raw byte 0x${block[0x11].toString(16).padStart(2, "0")}); ` +
1183
+ `refusing to guess it. Set a known mode with obsbot_image_fov (e.g. {fov:"wide"}) and retry.`,
1184
+ };
1185
+ }
1186
+ return {
1187
+ ok: false,
1188
+ error: `the camera's zoom reading (zoomPercent ${resolved.zoomPercent}) resolves to a ` +
1189
+ `magnification outside the camera's known range [${MIN_MAGNIFICATION}, ` +
1190
+ `${MAX_MAGNIFICATION}]; refusing rather than frame from what looks like a corrupt ` +
1191
+ `status read. Retry, or set a known mode with obsbot_image_fov.`,
1192
+ };
1193
+ }
1194
+ const magnification = resolved.magnification;
1195
+ // Same read path as obsbot_gimbal_position / obsbot_aim_at_pixel.
1196
+ const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
1197
+ const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
1198
+ // Aim uses the CURRENT magnification: it has to match the optics the
1199
+ // caller's frame was actually captured at, not the new fitted zoom.
1200
+ const centerX = x + width / 2;
1201
+ const centerY = y + height / 2;
1202
+ const aim = aimAtPixel(centerX, centerY, { width: frameWidth, height: frameHeight }, { magnification }, { yaw, pitch });
1203
+ if (aim.overTheTop) {
1204
+ return {
1205
+ ok: false,
1206
+ error: `region centre (${centerX},${centerY}) lies past vertical from the current pose (yaw ` +
1207
+ `${yaw.toFixed(1)}, pitch ${pitch.toFixed(1)}); it is not reachable while keeping the ` +
1208
+ `image upright, only by an "over the top" rotation that would swing the camera the ` +
1209
+ `opposite way. Tilt toward the region first (e.g. obsbot_gimbal_move), then re-aim.`,
1210
+ };
1211
+ }
1212
+ // required = m * min(frameW/width, frameH/height) / (1+margin). The MIN
1213
+ // (not MAX) is deliberate: it's the axis that needs LESS extra zoom to fill,
1214
+ // and zooming only that far keeps the OTHER axis from overflowing the frame
1215
+ // — i.e. it's what keeps the whole region visible instead of cropping it.
1216
+ // Note the frame's aspect ratio and VERTICAL_TANGENT_CORRECTION (src/geometry/aim.ts)
1217
+ // do NOT appear here: writing out the fit condition on each axis, those terms
1218
+ // scale tanH and tanV identically on both sides and cancel. They matter for
1219
+ // AIMING at the region's centre (already folded into `aim` above via
1220
+ // aimAtPixel) and not at all for how far to zoom.
1221
+ const requiredRaw = (magnification * Math.min(frameWidth / width, frameHeight / height)) / (1 + margin);
1222
+ const requiredMagnification = clamp(requiredRaw, MIN_MAGNIFICATION, MAX_MAGNIFICATION);
1223
+ const fitClamped = requiredMagnification !== requiredRaw;
1224
+ const ratio = zoomRatioFromMagnification(requiredMagnification);
1225
+ // Move BEFORE zoom. Zoom is centre-preserving but not target-preserving:
1226
+ // zooming first can push the region's centre out of frame entirely, after
1227
+ // which the move would be aiming at a pixel that no longer means what it
1228
+ // did when it was measured.
1229
+ await t.gimbalSet(aim.target.yaw, aim.target.pitch, 0);
1230
+ const { min, max } = await t.zoomRange();
1231
+ await t.zoomSet(zoomRatioToUnits(ratio, min, max));
1232
+ // Zoom ramps rather than jumping, so a status read taken immediately after
1233
+ // the write can catch it mid-transit — settled:false is a RESULT for the
1234
+ // caller to check, not thrown as an error, since a slower-than-expected
1235
+ // zoom is information, not a failure of this call.
1236
+ const settled = await waitForZoomSettle(t, ratio, zoomSettle);
1237
+ return {
1238
+ ok: true,
1239
+ target: aim.target,
1240
+ ratio,
1241
+ magnification: requiredMagnification,
1242
+ clamped: fitClamped || aim.clamped,
1243
+ settled,
1244
+ source,
1245
+ ...(source === "device" ? {} : { note: frameSourceNote(source) }),
850
1246
  ...(ready.reconnected ? { reconnected: true } : {}),
851
1247
  };
852
1248
  },
@@ -1228,7 +1624,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
1228
1624
  { type: "image", data: snap.base64, mimeType: snap.mime },
1229
1625
  {
1230
1626
  type: "text",
1231
- text: JSON.stringify({ width: snap.width, height: snap.height, source }),
1627
+ text: JSON.stringify({
1628
+ width: snap.width,
1629
+ height: snap.height,
1630
+ source,
1631
+ // Frame rate picks the field of view on this camera, so the
1632
+ // negotiated format is part of what a pixel means. Reported
1633
+ // when the platform helper knows it; see Snapshot.sourceFormat.
1634
+ ...(snap.sourceFormat ? { sourceFormat: snap.sourceFormat } : {}),
1635
+ }),
1232
1636
  },
1233
1637
  ],
1234
1638
  };