obsbot-mcp 0.5.0 → 0.6.1
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 +62 -12
- package/dist/capture/ffmpeg-args.d.ts +1 -0
- package/dist/capture/ffmpeg-args.js +14 -4
- 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 +8 -0
- package/dist/codec/commands.js +8 -0
- package/dist/codec/commands.js.map +1 -1
- package/dist/geometry/aim.d.ts +43 -10
- package/dist/geometry/aim.js +49 -8
- package/dist/geometry/aim.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/mcp/tools.d.ts +51 -1
- package/dist/mcp/tools.js +457 -53
- 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/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 +2 -1
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
738
|
-
"
|
|
739
|
-
"
|
|
740
|
-
"
|
|
741
|
-
"
|
|
742
|
-
"
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
784
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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: `
|
|
817
|
-
`
|
|
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
|
-
//
|
|
825
|
-
// mode's inherent crop
|
|
826
|
-
|
|
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({
|
|
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
|
};
|