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.
- package/README.md +133 -8
- package/dist/capture/ffmpeg-args.d.ts +1 -0
- package/dist/capture/ffmpeg-args.js +29 -2
- package/dist/capture/ffmpeg-args.js.map +1 -1
- package/dist/capture/manager.js +1 -1
- package/dist/capture/manager.js.map +1 -1
- package/dist/codec/commands.d.ts +11 -0
- package/dist/codec/commands.js +24 -0
- package/dist/codec/commands.js.map +1 -1
- package/dist/geometry/aim.d.ts +259 -0
- package/dist/geometry/aim.js +317 -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.d.ts +51 -1
- package/dist/mcp/tools.js +562 -16
- package/dist/mcp/tools.js.map +1 -1
- package/dist/transport/helper-process.js +4 -1
- package/dist/transport/helper-process.js.map +1 -1
- package/dist/transport/linux.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/dist/transport/transport.d.ts +14 -0
- package/dist/transport/transport.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
package/dist/mcp/tools.js
CHANGED
|
@@ -1,5 +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";
|
|
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";
|
|
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";
|
|
@@ -8,6 +9,61 @@ import { CameraBusyError } from "../transport/transport.js";
|
|
|
8
9
|
import { ensureReady, msg } from "./ready.js";
|
|
9
10
|
import { CaptureError } from "../capture/manager.js";
|
|
10
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
|
+
}
|
|
11
67
|
// Some MCP clients serialize numbers and booleans as strings when the advertised
|
|
12
68
|
// inputSchema lacks type info. We now advertise a proper JSON Schema (see
|
|
13
69
|
// mcp/server.ts), but also accept string-encoded values defensively so the tools
|
|
@@ -113,6 +169,33 @@ const imageExposureManualSchema = withCamera({
|
|
|
113
169
|
level: num().pipe(z.number().min(0).max(100)).default(50),
|
|
114
170
|
});
|
|
115
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.
|
|
177
|
+
const aimAtPixelSchema = withCamera({
|
|
178
|
+
x: num().pipe(z.number().finite()),
|
|
179
|
+
y: num().pipe(z.number().finite()),
|
|
180
|
+
frameWidth: num().pipe(z.number().finite().min(1)),
|
|
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"),
|
|
198
|
+
});
|
|
116
199
|
const presetListSchema = withCamera({});
|
|
117
200
|
const presetSaveSchema = withCamera({
|
|
118
201
|
slot: num().pipe(z.union([z.literal(1), z.literal(2), z.literal(3)])),
|
|
@@ -149,10 +232,15 @@ const PRESET_NAME_MAX = 40;
|
|
|
149
232
|
const snapshotSchema = withCamera({
|
|
150
233
|
resolution: num().pipe(z.number().min(256).max(1920)).default(640),
|
|
151
234
|
quality: num().pipe(z.number().min(1).max(100)).default(80),
|
|
152
|
-
|
|
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),
|
|
153
242
|
source: z.enum(["device", "virtual", "ndi"]).default("device"),
|
|
154
243
|
});
|
|
155
|
-
const captureSourceEnum = z.enum(["device", "virtual", "ndi"]);
|
|
156
244
|
const recordStartSchema = z.object({
|
|
157
245
|
durationSec: num().pipe(z.number().positive()).optional(),
|
|
158
246
|
audio: bool().default(true),
|
|
@@ -261,6 +349,147 @@ async function getPresetSlots(t) {
|
|
|
261
349
|
// window. Latency is the cheapest thing we can spend here.
|
|
262
350
|
const PRESET_READ_DEFAULTS = { attempts: 3, backoffMs: [200, 500, 1000] };
|
|
263
351
|
const napMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
352
|
+
// Pan/tilt readback is a float now (see transport/linux.ts, transport/macos.ts),
|
|
353
|
+
// which can carry many more digits than the hardware's ±1° accuracy justifies —
|
|
354
|
+
// e.g. 5.975277777777778 from an arc-second division. Round to 2 decimal places
|
|
355
|
+
// for reporting: enough to show sub-degree structure (useful on Linux, where it
|
|
356
|
+
// reflects the last commanded pose) without implying precision the readout
|
|
357
|
+
// doesn't have.
|
|
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.`;
|
|
264
493
|
const allEmpty = (slots) => slots.every((s) => !s.occupied);
|
|
265
494
|
/**
|
|
266
495
|
* Read the preset slots with bounded retry, and confirm the one verdict whose error
|
|
@@ -302,7 +531,7 @@ async function readPresetSlots(t, gate, opts = {}) {
|
|
|
302
531
|
}
|
|
303
532
|
throw lastErr;
|
|
304
533
|
}
|
|
305
|
-
export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
534
|
+
export function createTools(mgr, capture, debug = false, presetRead = {}, zoomSettle = {}) {
|
|
306
535
|
// Per-camera resolution derived from the manager. Every camera-addressing tool
|
|
307
536
|
// takes an optional `camera` selector (the serial); it threads through here so
|
|
308
537
|
// the transport, reconnect controller and readiness gate all target that one
|
|
@@ -375,13 +604,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
375
604
|
{
|
|
376
605
|
name: "obsbot_gimbal_move",
|
|
377
606
|
description: "Move the gimbal to an absolute yaw/pitch angle (degrees); positive yaw pans to the " +
|
|
378
|
-
|
|
379
|
-
|
|
607
|
+
`camera's left, positive pitch tilts down. Yaw is clamped to ` +
|
|
608
|
+
`[-${GIMBAL_YAW_LIMIT_DEG},${GIMBAL_YAW_LIMIT_DEG}], pitch to ` +
|
|
609
|
+
`[-${GIMBAL_PITCH_LIMIT_DEG},${GIMBAL_PITCH_LIMIT_DEG}]. ` +
|
|
610
|
+
"Absolute positioning (1:1 degrees), verified on hardware.",
|
|
380
611
|
schema: ptzMoveAngleSchema,
|
|
381
612
|
handler: async (args) => {
|
|
382
613
|
const parsed = ptzMoveAngleSchema.parse(args);
|
|
383
|
-
const yaw = clamp(parsed.yaw, -
|
|
384
|
-
const pitch = clamp(parsed.pitch, -
|
|
614
|
+
const yaw = clamp(parsed.yaw, -GIMBAL_YAW_LIMIT_DEG, GIMBAL_YAW_LIMIT_DEG);
|
|
615
|
+
const pitch = clamp(parsed.pitch, -GIMBAL_PITCH_LIMIT_DEG, GIMBAL_PITCH_LIMIT_DEG);
|
|
385
616
|
const roll = parsed.roll;
|
|
386
617
|
const ready = await gate(parsed.camera);
|
|
387
618
|
if (!ready.ok)
|
|
@@ -451,7 +682,11 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
451
682
|
name: "obsbot_zoom_uvc",
|
|
452
683
|
description: "Standard UVC zoom: set an absolute zoom ratio, clamped to [1.0, 2.0]. Snaps to the " +
|
|
453
684
|
"requested target exactly (unlike obsbot_zoom_vendor, whose ratio scale differs and " +
|
|
454
|
-
"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.",
|
|
455
690
|
schema: zoomAbsoluteSchema,
|
|
456
691
|
handler: async (args) => {
|
|
457
692
|
const parsed = zoomAbsoluteSchema.parse(args);
|
|
@@ -459,7 +694,14 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
459
694
|
const t = await getTransport(parsed.camera);
|
|
460
695
|
const { min, max } = await t.zoomRange();
|
|
461
696
|
await t.zoomSet(zoomRatioToUnits(ratio, min, max));
|
|
462
|
-
|
|
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 };
|
|
463
705
|
},
|
|
464
706
|
},
|
|
465
707
|
{
|
|
@@ -545,10 +787,19 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
545
787
|
},
|
|
546
788
|
{
|
|
547
789
|
name: "obsbot_status",
|
|
548
|
-
description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed
|
|
790
|
+
description: "Read the camera's live status block. Returns { awake, hdr, faceAe, aiMode, trackSpeed, " +
|
|
791
|
+
"fovMode, zoomPercent, focusMode, focusPosition }: " +
|
|
549
792
|
"faceAe is whether auto-exposure is metering for a detected face; " +
|
|
550
793
|
"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
|
|
794
|
+
"lower-body|desk|whiteboard|hand|group|unknown); trackSpeed is standard|sport|unknown; " +
|
|
795
|
+
"fovMode is the field-of-view mode (wide|medium|narrow|custom|unknown), where custom means " +
|
|
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. " +
|
|
552
803
|
"Under --debug the result also carries `raw`: the full 60-byte status block as hex " +
|
|
553
804
|
"(for reverse-engineering undecoded offsets).",
|
|
554
805
|
schema: getStatusSchema,
|
|
@@ -557,7 +808,12 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
557
808
|
const t = await getTransport(camera);
|
|
558
809
|
try {
|
|
559
810
|
const block = await t.recvStatus();
|
|
560
|
-
return {
|
|
811
|
+
return {
|
|
812
|
+
ok: true,
|
|
813
|
+
...decodeStatus(block),
|
|
814
|
+
...(await readFocus(t)),
|
|
815
|
+
...(debug ? { raw: block.toString("hex") } : {}),
|
|
816
|
+
};
|
|
561
817
|
}
|
|
562
818
|
catch (e) {
|
|
563
819
|
return { ok: false, error: `could not read camera status: ${e.message}` };
|
|
@@ -696,7 +952,8 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
696
952
|
name: "obsbot_gimbal_position",
|
|
697
953
|
description: "Read the gimbal's current absolute yaw/pitch in degrees (positive yaw = camera's " +
|
|
698
954
|
"left, positive pitch = down) via the standard UVC Pan/Tilt controls. This is a live " +
|
|
699
|
-
"hardware readout accurate to ±1
|
|
955
|
+
"hardware readout accurate to ±1°, reported rounded to 2 decimal places (finer digits " +
|
|
956
|
+
"would be noise, not precision): it is valid during a move as well as after one, " +
|
|
700
957
|
"and reflects motion the host did not command (speed moves, recenter, tracking).",
|
|
701
958
|
schema: gimbalPositionSchema,
|
|
702
959
|
handler: async (args) => {
|
|
@@ -706,7 +963,288 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
706
963
|
const tilt = await t.camCtrlGet(CAMERA_CONTROL_TILT);
|
|
707
964
|
// UVC pan value is degrees, same sign as our yaw (+ = camera-left). UVC tilt
|
|
708
965
|
// is degrees but positive = up, so negate to match our +pitch = down convention.
|
|
709
|
-
return { yaw: pan.value, pitch: -tilt.value };
|
|
966
|
+
return { yaw: round2(pan.value), pitch: round2(-tilt.value) };
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
name: "obsbot_aim_at_pixel",
|
|
971
|
+
description: "Point the camera at a specific pixel in a frame you just captured. Give the pixel's x/y " +
|
|
972
|
+
"and the frameWidth/frameHeight from THE SAME obsbot_capture_snapshot result — mixing a " +
|
|
973
|
+
"pixel from one frame with dimensions from another aims at the wrong place and cannot be " +
|
|
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 " +
|
|
985
|
+
"frame you measured, so the call refuses instead of aiming on stale geometry — take a fresh " +
|
|
986
|
+
"snapshot and retry. Returns clamped:true if the target was outside the gimbal's range; the " +
|
|
987
|
+
"camera still moves, to the nearest reachable pose. Refuses (ok:false) instead of moving when " +
|
|
988
|
+
"the pixel lies past vertical from the current pose — reachable only by an \"over the top\" " +
|
|
989
|
+
"rotation that would swing the camera toward the opposite side of the room, not toward the " +
|
|
990
|
+
"target; tilt toward the pixel first, then re-aim.",
|
|
991
|
+
schema: aimAtPixelSchema,
|
|
992
|
+
handler: async (args) => {
|
|
993
|
+
const { x, y, frameWidth, frameHeight, camera, source } = aimAtPixelSchema.parse(args);
|
|
994
|
+
const aspectRefusal = refuseIfNot169(frameWidth, frameHeight);
|
|
995
|
+
if (aspectRefusal)
|
|
996
|
+
return aspectRefusal;
|
|
997
|
+
if (x < 0 || x > frameWidth || y < 0 || y > frameHeight) {
|
|
998
|
+
return { ok: false, error: `pixel (${x},${y}) is outside the ${frameWidth}x${frameHeight} frame` };
|
|
999
|
+
}
|
|
1000
|
+
// The gate wakes a sleeping camera and waits for it to settle. If it had
|
|
1001
|
+
// to wake the camera, the gimbal just moved out from under the frame the
|
|
1002
|
+
// caller measured — refuse below rather than read a pose that no longer
|
|
1003
|
+
// corresponds to that frame.
|
|
1004
|
+
const ready = await gate(camera);
|
|
1005
|
+
if (!ready.ok)
|
|
1006
|
+
return ready;
|
|
1007
|
+
const t = ready.transport;
|
|
1008
|
+
if (ready.woke) {
|
|
1009
|
+
return {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
error: "the camera was asleep and waking it moved the gimbal, so the frame you measured no " +
|
|
1012
|
+
"longer matches where the camera is pointing. Take a fresh snapshot and aim again.",
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
const steady = await readSteadyStatus(t, zoomSettle);
|
|
1016
|
+
if (!steady.ok)
|
|
1017
|
+
return { ok: false, error: zoomMovingError(steady, "aiming") };
|
|
1018
|
+
const { block, status } = steady;
|
|
1019
|
+
if (status.aiMode === "unknown") {
|
|
1020
|
+
return {
|
|
1021
|
+
ok: false,
|
|
1022
|
+
error: "could not read the camera's AI-tracking mode (the status block didn't decode this " +
|
|
1023
|
+
"read — this can happen during a brief mode-switch transient); retry the aim.",
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
if (status.aiMode !== "no-tracking") {
|
|
1027
|
+
return {
|
|
1028
|
+
ok: false,
|
|
1029
|
+
error: `AI tracking is active (${status.aiMode}); it moves the gimbal itself and would ` +
|
|
1030
|
+
`fight the aim. Disable it with obsbot_ai_track {enabled:false} first.`,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
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.
|
|
1054
|
+
return {
|
|
1055
|
+
ok: false,
|
|
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.`,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
const magnification = resolved.magnification;
|
|
1063
|
+
// Same read path as obsbot_gimbal_position: UVC pan is degrees with our
|
|
1064
|
+
// yaw sign; UVC tilt is degrees but positive = up, so negate it.
|
|
1065
|
+
const yaw = (await t.camCtrlGet(CAMERA_CONTROL_PAN)).value;
|
|
1066
|
+
const pitch = -(await t.camCtrlGet(CAMERA_CONTROL_TILT)).value;
|
|
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 });
|
|
1071
|
+
// Over the top is not an ordinary clamp: the target ray points behind
|
|
1072
|
+
// the camera's current heading, so the nearest reachable yaw is on the
|
|
1073
|
+
// FAR side of the gimbal's range, not near the target. Moving there
|
|
1074
|
+
// would slew ~150 degrees into the opposite corner of the room while
|
|
1075
|
+
// reporting clamped:true, which reads as "landed short" rather than
|
|
1076
|
+
// "went the wrong way" — refuse instead of moving.
|
|
1077
|
+
if (aim.overTheTop) {
|
|
1078
|
+
return {
|
|
1079
|
+
ok: false,
|
|
1080
|
+
error: `pixel (${x},${y}) lies past vertical from the current pose (yaw ${yaw.toFixed(1)}, ` +
|
|
1081
|
+
`pitch ${pitch.toFixed(1)}); it is not reachable while keeping the image upright, only ` +
|
|
1082
|
+
`by an "over the top" rotation that would swing the camera the opposite way. Tilt toward ` +
|
|
1083
|
+
`the pixel first (e.g. obsbot_gimbal_move), then re-aim from the new pose.`,
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
await t.gimbalSet(aim.target.yaw, aim.target.pitch, 0);
|
|
1087
|
+
return {
|
|
1088
|
+
ok: true,
|
|
1089
|
+
target: aim.target,
|
|
1090
|
+
offset: aim.offset,
|
|
1091
|
+
clamped: aim.clamped,
|
|
1092
|
+
fovMode: status.fovMode,
|
|
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) }),
|
|
1246
|
+
...(ready.reconnected ? { reconnected: true } : {}),
|
|
1247
|
+
};
|
|
710
1248
|
},
|
|
711
1249
|
},
|
|
712
1250
|
{
|
|
@@ -1086,7 +1624,15 @@ export function createTools(mgr, capture, debug = false, presetRead = {}) {
|
|
|
1086
1624
|
{ type: "image", data: snap.base64, mimeType: snap.mime },
|
|
1087
1625
|
{
|
|
1088
1626
|
type: "text",
|
|
1089
|
-
text: JSON.stringify({
|
|
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
|
+
}),
|
|
1090
1636
|
},
|
|
1091
1637
|
],
|
|
1092
1638
|
};
|