@ksp-gonogo/kerbcast 1.6.3 → 1.7.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/CHANGELOG.md +22 -0
- package/README.md +16 -0
- package/dist/__generated__/types.d.ts +106 -2
- package/dist/__generated__/types.js +31 -0
- package/dist/client.d.ts +18 -1
- package/dist/client.js +21 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +34 -1
- package/package.json +1 -1
- package/src/__generated__/types.ts +105 -2
- package/src/client.test.ts +39 -0
- package/src/client.ts +24 -0
- package/src/testing/index.ts +47 -1
- package/src/testing.test.ts +43 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 - 2026-07-24
|
|
4
|
+
|
|
5
|
+
Crew face cameras, auto-resolution, and camera targeting.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `KerbcastClient.reportDisplaySize(flightId, width, height)`: report a
|
|
10
|
+
consumer's own displayed pixel size for a camera. The sidecar aggregates it
|
|
11
|
+
MAX-across-consumers to drive auto-resolution (distinct from `setRenderSize`,
|
|
12
|
+
which is the shared operator cap).
|
|
13
|
+
- `KerbcastClient.setTrackTarget(flightId, mode)`: ask a pan+zoom camera to
|
|
14
|
+
auto-track a moving vessel, or stop.
|
|
15
|
+
- `TrackMode` type (`"none" | "activeVessel" | "target"`).
|
|
16
|
+
- `trackMode?: TrackMode` on `CameraState` (WS push) and `CameraInfo`
|
|
17
|
+
(`GET /cameras`), server-authoritative so every consumer reflects the same
|
|
18
|
+
tracking state.
|
|
19
|
+
- `MockSidecar` accepts the new `report-display-size` and `set-track-target`
|
|
20
|
+
client messages (records them; `report-display-size` is advisory and does not
|
|
21
|
+
mutate render dims).
|
|
22
|
+
- `MockCameraInit` gains `kind`, `crewLocation`, `kerbalPersistentId`, and
|
|
23
|
+
`trackMode` for building crew and tracking fixtures.
|
|
24
|
+
|
|
3
25
|
## 0.12.0 - 2026-06-07
|
|
4
26
|
|
|
5
27
|
### Added
|
package/README.md
CHANGED
|
@@ -106,6 +106,22 @@ controls each part actually supports.
|
|
|
106
106
|
skip, producing the macroblocking and stuttering aesthetic of
|
|
107
107
|
in-game CommNet signal loss. Also a real CPU optimisation when
|
|
108
108
|
every consumer wants degraded video.
|
|
109
|
+
- `kind` (`"part"` | `"kerbal"`) and, for a kerbal face camera,
|
|
110
|
+
`crewLocation` (`"seat"` | `"eva"`). Distinguish crew face cameras
|
|
111
|
+
from part cameras.
|
|
112
|
+
- `trackMode` (`"none"` | `"activeVessel"` | `"target"`). Server-authoritative
|
|
113
|
+
auto-track state on a pan+zoom camera, so every consumer reflects the same
|
|
114
|
+
tracking state.
|
|
115
|
+
|
|
116
|
+
## Auto-resolution and targeting
|
|
117
|
+
|
|
118
|
+
- `client.reportDisplaySize(flightId, width, height)`. Report a consumer's
|
|
119
|
+
displayed pixel size for a camera; the sidecar takes the MAX across consumers
|
|
120
|
+
to size the encode (distinct from `setRenderSize`, the shared operator cap).
|
|
121
|
+
The react feed primitives self-measure and call this for you.
|
|
122
|
+
- `client.setTrackTarget(flightId, mode)`. Ask a pan+zoom camera to auto-track
|
|
123
|
+
the active vessel or its target (`TrackMode`), or `"none"` to stop. The
|
|
124
|
+
chosen mode is published back on `CameraState.trackMode`.
|
|
109
125
|
|
|
110
126
|
## Pan/zoom input helper
|
|
111
127
|
|
|
@@ -14,6 +14,34 @@ export declare enum CameraLifecycle {
|
|
|
14
14
|
Active = "active",
|
|
15
15
|
Destroyed = "destroyed"
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Distinguishes an existing Hullcam part camera (`Part`) from a
|
|
19
|
+
* per-kerbal face camera (`Kerbal`).
|
|
20
|
+
*/
|
|
21
|
+
export declare enum CameraKind {
|
|
22
|
+
Part = "part",
|
|
23
|
+
Kerbal = "kerbal"
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Which source a kerbal camera is currently rendering: a seated IVA
|
|
27
|
+
* portrait, or the kerbal's own view while on EVA. Only meaningful when
|
|
28
|
+
* `CameraState.kind == Kerbal`.
|
|
29
|
+
*/
|
|
30
|
+
export declare enum CrewLocation {
|
|
31
|
+
Seat = "seat",
|
|
32
|
+
Eva = "eva"
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
|
|
36
|
+
* off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
|
|
37
|
+
* position from `FlightGlobals` (the active vessel, or its target) with no
|
|
38
|
+
* orbit math. Only surfaces on cameras with BOTH pan and zoom.
|
|
39
|
+
*/
|
|
40
|
+
export declare enum TrackMode {
|
|
41
|
+
None = "none",
|
|
42
|
+
ActiveVessel = "activeVessel",
|
|
43
|
+
Target = "target"
|
|
44
|
+
}
|
|
17
45
|
/**
|
|
18
46
|
* Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
|
|
19
47
|
* Receiving clients use this for both the rendered-layer status reports
|
|
@@ -62,6 +90,29 @@ export interface CameraState {
|
|
|
62
90
|
* surface a "SIGNAL LOST" overlay and keep the last frame visible.
|
|
63
91
|
*/
|
|
64
92
|
lifecycle?: CameraLifecycle;
|
|
93
|
+
/**
|
|
94
|
+
* Whether this is a Hullcam part camera or a per-kerbal face camera.
|
|
95
|
+
* Defaults to `Part` so existing payloads are unaffected.
|
|
96
|
+
*/
|
|
97
|
+
kind?: CameraKind;
|
|
98
|
+
/**
|
|
99
|
+
* The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
|
|
100
|
+
* consumer correlates against. `None` for part cameras. Distinct from
|
|
101
|
+
* `flight_id`, which identifies the camera instance, not the kerbal.
|
|
102
|
+
*/
|
|
103
|
+
kerbalPersistentId?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Which source a kerbal camera is currently rendering. `None` for
|
|
106
|
+
* part cameras.
|
|
107
|
+
*/
|
|
108
|
+
crewLocation?: CrewLocation;
|
|
109
|
+
/**
|
|
110
|
+
* Server-authoritative auto-track state for a pan+zoom camera (the
|
|
111
|
+
* operator's persistent intent). `None` (the default) = not tracking.
|
|
112
|
+
* Every browser reflects this; a browser track overrides a kOS aim on the
|
|
113
|
+
* same camera (send `SetTrackTarget` with `none` to hand back to kOS).
|
|
114
|
+
*/
|
|
115
|
+
trackMode?: TrackMode;
|
|
65
116
|
partName: string;
|
|
66
117
|
partTitle: string;
|
|
67
118
|
cameraName: string;
|
|
@@ -175,6 +226,19 @@ export interface HelloPayload {
|
|
|
175
226
|
sidecarVersion: string;
|
|
176
227
|
encoderBackend: string;
|
|
177
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* A consumer reporting its OWN current display size in px for one camera.
|
|
231
|
+
* Unlike `SetRenderSizePayload` (an operator command that sets the shared
|
|
232
|
+
* render size directly, last-writer-wins), this is a per-consumer input the
|
|
233
|
+
* sidecar aggregates MAX-across-consumers to drive auto-resolution
|
|
234
|
+
* (meet-the-minimum-need). Shape mirrors `SetRenderSizePayload`; `width` and
|
|
235
|
+
* `height` are the consumer's display px for this feed.
|
|
236
|
+
*/
|
|
237
|
+
export interface ReportDisplaySizePayload {
|
|
238
|
+
flightId: number;
|
|
239
|
+
width: number;
|
|
240
|
+
height: number;
|
|
241
|
+
}
|
|
178
242
|
/**
|
|
179
243
|
* Scene-state change: whether KSP is currently in a flight scene. Sent
|
|
180
244
|
* after `Hello` (priming) and whenever the polled in-flight flag flips,
|
|
@@ -248,6 +312,21 @@ export interface SetThrottleMainScreenPayload {
|
|
|
248
312
|
*/
|
|
249
313
|
enabled: boolean;
|
|
250
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
|
|
317
|
+
* `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
|
|
318
|
+
* chosen mode per camera and publishes it in `CameraState.track_mode`, so
|
|
319
|
+
* every browser reflects the same tracking state (never optimistic-local).
|
|
320
|
+
*
|
|
321
|
+
* Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
|
|
322
|
+
* same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
|
|
323
|
+
* camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
|
|
324
|
+
* zoom over any manual FoV; `mode: none` restores manual zoom untouched.
|
|
325
|
+
*/
|
|
326
|
+
export interface SetTrackTargetPayload {
|
|
327
|
+
flightId: number;
|
|
328
|
+
mode: TrackMode;
|
|
329
|
+
}
|
|
251
330
|
export interface SetZoomRatePayload {
|
|
252
331
|
flightId: number;
|
|
253
332
|
/**
|
|
@@ -324,13 +403,38 @@ export type ClientMessage =
|
|
|
324
403
|
content: SetLayersPayload;
|
|
325
404
|
}
|
|
326
405
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
406
|
+
* Cap the auto-resolution for one camera. As of Stage 6 this is a
|
|
407
|
+
* CEILING on the demand-driven size (effective = min(auto max-across-
|
|
408
|
+
* consumers, this cap)), NOT an authoritative set: it cannot force a
|
|
409
|
+
* camera larger than current consumers report, only smaller. Even
|
|
410
|
+
* pixels only (H.264 chroma); server caps at the ring's allocated max.
|
|
329
411
|
*/
|
|
330
412
|
| {
|
|
331
413
|
type: "set-render-size";
|
|
332
414
|
content: SetRenderSizePayload;
|
|
333
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
|
|
418
|
+
* holds the chosen `mode` per camera and publishes it in
|
|
419
|
+
* `CameraState.track_mode`, so tracking state is server-authoritative and
|
|
420
|
+
* consistent across browsers. A browser track OVERRIDES a kOS aim on the
|
|
421
|
+
* same camera; send `mode: none` to hand aiming back to kOS.
|
|
422
|
+
*/
|
|
423
|
+
| {
|
|
424
|
+
type: "set-track-target";
|
|
425
|
+
content: SetTrackTargetPayload;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* A consumer reporting its OWN current display size in px; the sidecar
|
|
429
|
+
* aggregates MAX-across-consumers to drive auto-resolution
|
|
430
|
+
* (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
|
|
431
|
+
* command: this is a per-consumer input, not a shared override — a
|
|
432
|
+
* departing consumer's size is cleared so the max relaxes.
|
|
433
|
+
*/
|
|
434
|
+
| {
|
|
435
|
+
type: "report-display-size";
|
|
436
|
+
content: ReportDisplaySizePayload;
|
|
437
|
+
}
|
|
334
438
|
/**
|
|
335
439
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
336
440
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
|
@@ -13,6 +13,37 @@ export var CameraLifecycle;
|
|
|
13
13
|
CameraLifecycle["Active"] = "active";
|
|
14
14
|
CameraLifecycle["Destroyed"] = "destroyed";
|
|
15
15
|
})(CameraLifecycle || (CameraLifecycle = {}));
|
|
16
|
+
/**
|
|
17
|
+
* Distinguishes an existing Hullcam part camera (`Part`) from a
|
|
18
|
+
* per-kerbal face camera (`Kerbal`).
|
|
19
|
+
*/
|
|
20
|
+
export var CameraKind;
|
|
21
|
+
(function (CameraKind) {
|
|
22
|
+
CameraKind["Part"] = "part";
|
|
23
|
+
CameraKind["Kerbal"] = "kerbal";
|
|
24
|
+
})(CameraKind || (CameraKind = {}));
|
|
25
|
+
/**
|
|
26
|
+
* Which source a kerbal camera is currently rendering: a seated IVA
|
|
27
|
+
* portrait, or the kerbal's own view while on EVA. Only meaningful when
|
|
28
|
+
* `CameraState.kind == Kerbal`.
|
|
29
|
+
*/
|
|
30
|
+
export var CrewLocation;
|
|
31
|
+
(function (CrewLocation) {
|
|
32
|
+
CrewLocation["Seat"] = "seat";
|
|
33
|
+
CrewLocation["Eva"] = "eva";
|
|
34
|
+
})(CrewLocation || (CrewLocation = {}));
|
|
35
|
+
/**
|
|
36
|
+
* Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
|
|
37
|
+
* off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
|
|
38
|
+
* position from `FlightGlobals` (the active vessel, or its target) with no
|
|
39
|
+
* orbit math. Only surfaces on cameras with BOTH pan and zoom.
|
|
40
|
+
*/
|
|
41
|
+
export var TrackMode;
|
|
42
|
+
(function (TrackMode) {
|
|
43
|
+
TrackMode["None"] = "none";
|
|
44
|
+
TrackMode["ActiveVessel"] = "activeVessel";
|
|
45
|
+
TrackMode["Target"] = "target";
|
|
46
|
+
})(TrackMode || (TrackMode = {}));
|
|
16
47
|
/**
|
|
17
48
|
* Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
|
|
18
49
|
* Receiving clients use this for both the rendered-layer status reports
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload } from "./__generated__/types";
|
|
1
|
+
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
|
|
2
2
|
import { CameraLifecycle } from "./__generated__/types";
|
|
3
3
|
/** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
|
|
4
4
|
export interface NoiseConfig {
|
|
@@ -409,6 +409,23 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
409
409
|
* goes null once the sidecar confirms with a SlotMap.
|
|
410
410
|
*/
|
|
411
411
|
unsubscribe(flightId: number): Promise<void>;
|
|
412
|
+
/**
|
|
413
|
+
* Report this consumer's own displayed pixel size for a camera, driving the
|
|
414
|
+
* sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
|
|
415
|
+
* sets the shared render size directly, last-writer-wins), this is a
|
|
416
|
+
* per-consumer input the sidecar aggregates MAX-across-consumers, so a big
|
|
417
|
+
* spotlight bumps the stream up while tiny avatars leave it small. The SDK's
|
|
418
|
+
* feed primitives self-measure and call this; consumers do not report by hand.
|
|
419
|
+
*/
|
|
420
|
+
reportDisplaySize(flightId: number, width: number, height: number): Promise<void>;
|
|
421
|
+
/**
|
|
422
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
|
|
423
|
+
* intent; the sidecar holds the chosen mode per camera and publishes it in
|
|
424
|
+
* `CameraState.trackMode`, so the UI must reflect that server-confirmed value
|
|
425
|
+
* rather than this call (two browsers stay in agreement). While tracking, the
|
|
426
|
+
* camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
|
|
427
|
+
*/
|
|
428
|
+
setTrackTarget(flightId: number, mode: TrackMode): Promise<void>;
|
|
412
429
|
/**
|
|
413
430
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
414
431
|
* merging the per-camera override (if any) with the client-level default.
|
package/dist/client.js
CHANGED
|
@@ -658,6 +658,27 @@ export class KerbcastClient extends TypedEmitter {
|
|
|
658
658
|
async unsubscribe(flightId) {
|
|
659
659
|
await this._send({ type: "unsubscribe", content: { flightId } });
|
|
660
660
|
}
|
|
661
|
+
/**
|
|
662
|
+
* Report this consumer's own displayed pixel size for a camera, driving the
|
|
663
|
+
* sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
|
|
664
|
+
* sets the shared render size directly, last-writer-wins), this is a
|
|
665
|
+
* per-consumer input the sidecar aggregates MAX-across-consumers, so a big
|
|
666
|
+
* spotlight bumps the stream up while tiny avatars leave it small. The SDK's
|
|
667
|
+
* feed primitives self-measure and call this; consumers do not report by hand.
|
|
668
|
+
*/
|
|
669
|
+
async reportDisplaySize(flightId, width, height) {
|
|
670
|
+
await this._send({ type: "report-display-size", content: { flightId, width, height } });
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
|
|
674
|
+
* intent; the sidecar holds the chosen mode per camera and publishes it in
|
|
675
|
+
* `CameraState.trackMode`, so the UI must reflect that server-confirmed value
|
|
676
|
+
* rather than this call (two browsers stay in agreement). While tracking, the
|
|
677
|
+
* camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
|
|
678
|
+
*/
|
|
679
|
+
async setTrackTarget(flightId, mode) {
|
|
680
|
+
await this._send({ type: "set-track-target", content: { flightId, mode } });
|
|
681
|
+
}
|
|
661
682
|
/**
|
|
662
683
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
663
684
|
* merging the per-camera override (if any) with the client-level default.
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
|
|
2
|
-
import { CameraLifecycle, Layer, QualityPreset } from "../__generated__/types";
|
|
2
|
+
import { CameraKind, CameraLifecycle, CrewLocation, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
3
3
|
import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
|
|
4
4
|
export interface MockCameraInit {
|
|
5
5
|
flightId: number;
|
|
6
6
|
lifecycle?: CameraLifecycle;
|
|
7
|
+
/** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
|
|
8
|
+
* part-cam callers are unchanged. */
|
|
9
|
+
kind?: CameraKind;
|
|
10
|
+
/** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
|
|
11
|
+
crewLocation?: CrewLocation;
|
|
12
|
+
/** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
|
|
13
|
+
kerbalPersistentId?: number;
|
|
7
14
|
partName?: string;
|
|
8
15
|
partTitle?: string;
|
|
9
16
|
cameraName?: string;
|
|
@@ -30,6 +37,8 @@ export interface MockCameraInit {
|
|
|
30
37
|
degradeLevel?: number;
|
|
31
38
|
viewerQuality?: QualityPreset;
|
|
32
39
|
qualityLimitedBy?: string;
|
|
40
|
+
/** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
|
|
41
|
+
trackMode?: TrackMode;
|
|
33
42
|
}
|
|
34
43
|
/**
|
|
35
44
|
* In-process protocol-level fake for the kerbcast sidecar.
|
|
@@ -61,6 +70,7 @@ export declare class MockSidecar {
|
|
|
61
70
|
private _clientMsgHandler;
|
|
62
71
|
private _stateHandler;
|
|
63
72
|
private _onTrackHandler;
|
|
73
|
+
private _subscribeHandler;
|
|
64
74
|
private _trackIdx;
|
|
65
75
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
66
76
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -134,6 +144,13 @@ export declare class MockSidecar {
|
|
|
134
144
|
* `trackIdentifier` matching what the client received.
|
|
135
145
|
*/
|
|
136
146
|
deliverTrack(mid: string, track: MediaStreamTrack): void;
|
|
147
|
+
/**
|
|
148
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
149
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
150
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
151
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
152
|
+
*/
|
|
153
|
+
onSubscribe(handler: (flightId: number, mid: string) => void): void;
|
|
137
154
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
138
155
|
slotMidFor(flightId: number): string | undefined;
|
|
139
156
|
/** Every `ClientMessage` received from the client, in order. */
|
package/dist/testing/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
|
|
1
|
+
import { CameraKind, CameraLifecycle, ErrorSource, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
2
2
|
function buildCamera(init) {
|
|
3
3
|
return {
|
|
4
4
|
flightId: init.flightId,
|
|
5
5
|
lifecycle: init.lifecycle ?? CameraLifecycle.Active,
|
|
6
|
+
kind: init.kind ?? CameraKind.Part,
|
|
7
|
+
crewLocation: init.crewLocation,
|
|
8
|
+
kerbalPersistentId: init.kerbalPersistentId,
|
|
6
9
|
partName: init.partName ?? `part-${init.flightId}`,
|
|
7
10
|
partTitle: init.partTitle ?? `Part ${init.flightId}`,
|
|
8
11
|
cameraName: init.cameraName ?? `camera-${init.flightId}`,
|
|
@@ -29,6 +32,7 @@ function buildCamera(init) {
|
|
|
29
32
|
degradeLevel: init.degradeLevel ?? 0,
|
|
30
33
|
viewerQuality: init.viewerQuality,
|
|
31
34
|
qualityLimitedBy: init.qualityLimitedBy,
|
|
35
|
+
trackMode: init.trackMode ?? TrackMode.None,
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
/** Fraction of the operator render size each preset targets (mirrors the
|
|
@@ -233,6 +237,15 @@ export class MockSidecar {
|
|
|
233
237
|
this._deliveredTracks.set(mid, track);
|
|
234
238
|
this._onTrackHandler?.(track, this._trackIdx++, mid);
|
|
235
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
242
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
243
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
244
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
245
|
+
*/
|
|
246
|
+
onSubscribe(handler) {
|
|
247
|
+
this._subscribeHandler = handler;
|
|
248
|
+
}
|
|
236
249
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
237
250
|
slotMidFor(flightId) {
|
|
238
251
|
for (const [mid, fid] of this._slotBindings) {
|
|
@@ -439,6 +452,18 @@ export class MockSidecar {
|
|
|
439
452
|
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
440
453
|
break;
|
|
441
454
|
}
|
|
455
|
+
case "set-track-target": {
|
|
456
|
+
// Server-authoritative: hold the chosen mode and broadcast it back as
|
|
457
|
+
// camera-state-changed, mirroring the sidecar so every browser reflects
|
|
458
|
+
// the same trackMode (never optimistic-local).
|
|
459
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
460
|
+
if (cam) {
|
|
461
|
+
const updated = { ...cam, trackMode: msg.content.mode };
|
|
462
|
+
this._cameras.set(msg.content.flightId, updated);
|
|
463
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
442
467
|
case "subscribe": {
|
|
443
468
|
const flightId = msg.content.flightId;
|
|
444
469
|
const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
|
|
@@ -454,6 +479,9 @@ export class MockSidecar {
|
|
|
454
479
|
type: "slot-map",
|
|
455
480
|
content: { mid: freeMid, flightId },
|
|
456
481
|
});
|
|
482
|
+
// Let a harness deliver this camera's track to the slot it was just
|
|
483
|
+
// bound to (so tracks follow the actual subscription, not array order).
|
|
484
|
+
this._subscribeHandler?.(flightId, freeMid);
|
|
457
485
|
}
|
|
458
486
|
break;
|
|
459
487
|
}
|
|
@@ -479,6 +507,11 @@ export class MockSidecar {
|
|
|
479
507
|
// the mock has no frame clock, so it doesn't model their *effect* on
|
|
480
508
|
// panYaw/fov. They're still recorded in `_commands`, so consumer tests
|
|
481
509
|
// can assert the command was sent via `lastCommand("set-pan-rate")`.
|
|
510
|
+
// Advisory per-consumer display-size input. The real sidecar aggregates
|
|
511
|
+
// it MAX-across-consumers to drive auto-resolution; the mock has no
|
|
512
|
+
// aggregator, so it just records the command (via `_commands`) and does
|
|
513
|
+
// NOT mutate camera render dims (unlike the operator `set-render-size`).
|
|
514
|
+
case "report-display-size":
|
|
482
515
|
case "set-pan-rate":
|
|
483
516
|
case "set-zoom-rate":
|
|
484
517
|
case "hello":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|
|
@@ -20,6 +20,37 @@ export enum CameraLifecycle {
|
|
|
20
20
|
Destroyed = "destroyed",
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Distinguishes an existing Hullcam part camera (`Part`) from a
|
|
25
|
+
* per-kerbal face camera (`Kerbal`).
|
|
26
|
+
*/
|
|
27
|
+
export enum CameraKind {
|
|
28
|
+
Part = "part",
|
|
29
|
+
Kerbal = "kerbal",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which source a kerbal camera is currently rendering: a seated IVA
|
|
34
|
+
* portrait, or the kerbal's own view while on EVA. Only meaningful when
|
|
35
|
+
* `CameraState.kind == Kerbal`.
|
|
36
|
+
*/
|
|
37
|
+
export enum CrewLocation {
|
|
38
|
+
Seat = "seat",
|
|
39
|
+
Eva = "eva",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
|
|
44
|
+
* off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
|
|
45
|
+
* position from `FlightGlobals` (the active vessel, or its target) with no
|
|
46
|
+
* orbit math. Only surfaces on cameras with BOTH pan and zoom.
|
|
47
|
+
*/
|
|
48
|
+
export enum TrackMode {
|
|
49
|
+
None = "none",
|
|
50
|
+
ActiveVessel = "activeVessel",
|
|
51
|
+
Target = "target",
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/**
|
|
24
55
|
* Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
|
|
25
56
|
* Receiving clients use this for both the rendered-layer status reports
|
|
@@ -70,6 +101,29 @@ export interface CameraState {
|
|
|
70
101
|
* surface a "SIGNAL LOST" overlay and keep the last frame visible.
|
|
71
102
|
*/
|
|
72
103
|
lifecycle?: CameraLifecycle;
|
|
104
|
+
/**
|
|
105
|
+
* Whether this is a Hullcam part camera or a per-kerbal face camera.
|
|
106
|
+
* Defaults to `Part` so existing payloads are unaffected.
|
|
107
|
+
*/
|
|
108
|
+
kind?: CameraKind;
|
|
109
|
+
/**
|
|
110
|
+
* The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
|
|
111
|
+
* consumer correlates against. `None` for part cameras. Distinct from
|
|
112
|
+
* `flight_id`, which identifies the camera instance, not the kerbal.
|
|
113
|
+
*/
|
|
114
|
+
kerbalPersistentId?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Which source a kerbal camera is currently rendering. `None` for
|
|
117
|
+
* part cameras.
|
|
118
|
+
*/
|
|
119
|
+
crewLocation?: CrewLocation;
|
|
120
|
+
/**
|
|
121
|
+
* Server-authoritative auto-track state for a pan+zoom camera (the
|
|
122
|
+
* operator's persistent intent). `None` (the default) = not tracking.
|
|
123
|
+
* Every browser reflects this; a browser track overrides a kOS aim on the
|
|
124
|
+
* same camera (send `SetTrackTarget` with `none` to hand back to kOS).
|
|
125
|
+
*/
|
|
126
|
+
trackMode?: TrackMode;
|
|
73
127
|
partName: string;
|
|
74
128
|
partTitle: string;
|
|
75
129
|
cameraName: string;
|
|
@@ -190,6 +244,20 @@ export interface HelloPayload {
|
|
|
190
244
|
encoderBackend: string;
|
|
191
245
|
}
|
|
192
246
|
|
|
247
|
+
/**
|
|
248
|
+
* A consumer reporting its OWN current display size in px for one camera.
|
|
249
|
+
* Unlike `SetRenderSizePayload` (an operator command that sets the shared
|
|
250
|
+
* render size directly, last-writer-wins), this is a per-consumer input the
|
|
251
|
+
* sidecar aggregates MAX-across-consumers to drive auto-resolution
|
|
252
|
+
* (meet-the-minimum-need). Shape mirrors `SetRenderSizePayload`; `width` and
|
|
253
|
+
* `height` are the consumer's display px for this feed.
|
|
254
|
+
*/
|
|
255
|
+
export interface ReportDisplaySizePayload {
|
|
256
|
+
flightId: number;
|
|
257
|
+
width: number;
|
|
258
|
+
height: number;
|
|
259
|
+
}
|
|
260
|
+
|
|
193
261
|
/**
|
|
194
262
|
* Scene-state change: whether KSP is currently in a flight scene. Sent
|
|
195
263
|
* after `Hello` (priming) and whenever the polled in-flight flag flips,
|
|
@@ -272,6 +340,22 @@ export interface SetThrottleMainScreenPayload {
|
|
|
272
340
|
enabled: boolean;
|
|
273
341
|
}
|
|
274
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
|
|
345
|
+
* `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
|
|
346
|
+
* chosen mode per camera and publishes it in `CameraState.track_mode`, so
|
|
347
|
+
* every browser reflects the same tracking state (never optimistic-local).
|
|
348
|
+
*
|
|
349
|
+
* Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
|
|
350
|
+
* same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
|
|
351
|
+
* camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
|
|
352
|
+
* zoom over any manual FoV; `mode: none` restores manual zoom untouched.
|
|
353
|
+
*/
|
|
354
|
+
export interface SetTrackTargetPayload {
|
|
355
|
+
flightId: number;
|
|
356
|
+
mode: TrackMode;
|
|
357
|
+
}
|
|
358
|
+
|
|
275
359
|
export interface SetZoomRatePayload {
|
|
276
360
|
flightId: number;
|
|
277
361
|
/**
|
|
@@ -345,10 +429,29 @@ export type ClientMessage =
|
|
|
345
429
|
*/
|
|
346
430
|
| { type: "set-layers", content: SetLayersPayload }
|
|
347
431
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
432
|
+
* Cap the auto-resolution for one camera. As of Stage 6 this is a
|
|
433
|
+
* CEILING on the demand-driven size (effective = min(auto max-across-
|
|
434
|
+
* consumers, this cap)), NOT an authoritative set: it cannot force a
|
|
435
|
+
* camera larger than current consumers report, only smaller. Even
|
|
436
|
+
* pixels only (H.264 chroma); server caps at the ring's allocated max.
|
|
350
437
|
*/
|
|
351
438
|
| { type: "set-render-size", content: SetRenderSizePayload }
|
|
439
|
+
/**
|
|
440
|
+
* Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
|
|
441
|
+
* holds the chosen `mode` per camera and publishes it in
|
|
442
|
+
* `CameraState.track_mode`, so tracking state is server-authoritative and
|
|
443
|
+
* consistent across browsers. A browser track OVERRIDES a kOS aim on the
|
|
444
|
+
* same camera; send `mode: none` to hand aiming back to kOS.
|
|
445
|
+
*/
|
|
446
|
+
| { type: "set-track-target", content: SetTrackTargetPayload }
|
|
447
|
+
/**
|
|
448
|
+
* A consumer reporting its OWN current display size in px; the sidecar
|
|
449
|
+
* aggregates MAX-across-consumers to drive auto-resolution
|
|
450
|
+
* (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
|
|
451
|
+
* command: this is a per-consumer input, not a shared override — a
|
|
452
|
+
* departing consumer's size is cleared so the max relaxes.
|
|
453
|
+
*/
|
|
454
|
+
| { type: "report-display-size", content: ReportDisplaySizePayload }
|
|
352
455
|
/**
|
|
353
456
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
354
457
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
package/src/client.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
ErrorSource,
|
|
5
5
|
Layer,
|
|
6
6
|
QualityPreset,
|
|
7
|
+
TrackMode,
|
|
7
8
|
} from "./__generated__/types";
|
|
8
9
|
import {
|
|
9
10
|
BrowserKerbcastTransport,
|
|
@@ -381,6 +382,44 @@ describe("KerbcastClient", () => {
|
|
|
381
382
|
]);
|
|
382
383
|
});
|
|
383
384
|
|
|
385
|
+
it("reportDisplaySize sends a report-display-size command", async () => {
|
|
386
|
+
const { transport, captured } = makeFakeTransport();
|
|
387
|
+
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
|
388
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
|
|
389
|
+
|
|
390
|
+
await client.connect();
|
|
391
|
+
captured.dc?._open();
|
|
392
|
+
captured.dc!.sent.length = 0;
|
|
393
|
+
|
|
394
|
+
await client.reportDisplaySize(3, 40, 40);
|
|
395
|
+
|
|
396
|
+
expect(captured.dc?.sent).toEqual([
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
type: "report-display-size",
|
|
399
|
+
content: { flightId: 3, width: 40, height: 40 },
|
|
400
|
+
}),
|
|
401
|
+
]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("setTrackTarget sends a set-track-target command", async () => {
|
|
405
|
+
const { transport, captured } = makeFakeTransport();
|
|
406
|
+
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
|
407
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
|
|
408
|
+
|
|
409
|
+
await client.connect();
|
|
410
|
+
captured.dc?._open();
|
|
411
|
+
captured.dc!.sent.length = 0;
|
|
412
|
+
|
|
413
|
+
await client.setTrackTarget(5, TrackMode.ActiveVessel);
|
|
414
|
+
|
|
415
|
+
expect(captured.dc?.sent).toEqual([
|
|
416
|
+
JSON.stringify({
|
|
417
|
+
type: "set-track-target",
|
|
418
|
+
content: { flightId: 5, mode: TrackMode.ActiveVessel },
|
|
419
|
+
}),
|
|
420
|
+
]);
|
|
421
|
+
});
|
|
422
|
+
|
|
384
423
|
it("disconnect tears down peer + emits null MediaStream events", async () => {
|
|
385
424
|
const { transport, captured } = makeFakeTransport();
|
|
386
425
|
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
QualityPreset,
|
|
8
8
|
ServerMessage,
|
|
9
9
|
SettingsStatePayload,
|
|
10
|
+
TrackMode,
|
|
10
11
|
} from "./__generated__/types";
|
|
11
12
|
import { CameraLifecycle, ErrorSource } from "./__generated__/types";
|
|
12
13
|
import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
|
|
@@ -1040,6 +1041,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
1040
1041
|
await this._send({ type: "unsubscribe", content: { flightId } });
|
|
1041
1042
|
}
|
|
1042
1043
|
|
|
1044
|
+
/**
|
|
1045
|
+
* Report this consumer's own displayed pixel size for a camera, driving the
|
|
1046
|
+
* sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
|
|
1047
|
+
* sets the shared render size directly, last-writer-wins), this is a
|
|
1048
|
+
* per-consumer input the sidecar aggregates MAX-across-consumers, so a big
|
|
1049
|
+
* spotlight bumps the stream up while tiny avatars leave it small. The SDK's
|
|
1050
|
+
* feed primitives self-measure and call this; consumers do not report by hand.
|
|
1051
|
+
*/
|
|
1052
|
+
async reportDisplaySize(flightId: number, width: number, height: number): Promise<void> {
|
|
1053
|
+
await this._send({ type: "report-display-size", content: { flightId, width, height } });
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
|
|
1058
|
+
* intent; the sidecar holds the chosen mode per camera and publishes it in
|
|
1059
|
+
* `CameraState.trackMode`, so the UI must reflect that server-confirmed value
|
|
1060
|
+
* rather than this call (two browsers stay in agreement). While tracking, the
|
|
1061
|
+
* camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
|
|
1062
|
+
*/
|
|
1063
|
+
async setTrackTarget(flightId: number, mode: TrackMode): Promise<void> {
|
|
1064
|
+
await this._send({ type: "set-track-target", content: { flightId, mode } });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1043
1067
|
/**
|
|
1044
1068
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
1045
1069
|
* merging the per-camera override (if any) with the client-level default.
|
package/src/testing/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, ServerMessage, SettingsStatePayload } from "../__generated__/types";
|
|
2
|
-
import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
|
|
2
|
+
import { CameraKind, CameraLifecycle, CrewLocation, ErrorSource, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
3
3
|
import type {
|
|
4
4
|
InboundVideoStats,
|
|
5
5
|
KerbcastConnectionState,
|
|
@@ -11,6 +11,13 @@ import type {
|
|
|
11
11
|
export interface MockCameraInit {
|
|
12
12
|
flightId: number;
|
|
13
13
|
lifecycle?: CameraLifecycle;
|
|
14
|
+
/** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
|
|
15
|
+
* part-cam callers are unchanged. */
|
|
16
|
+
kind?: CameraKind;
|
|
17
|
+
/** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
|
|
18
|
+
crewLocation?: CrewLocation;
|
|
19
|
+
/** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
|
|
20
|
+
kerbalPersistentId?: number;
|
|
14
21
|
partName?: string;
|
|
15
22
|
partTitle?: string;
|
|
16
23
|
cameraName?: string;
|
|
@@ -37,12 +44,17 @@ export interface MockCameraInit {
|
|
|
37
44
|
degradeLevel?: number;
|
|
38
45
|
viewerQuality?: QualityPreset;
|
|
39
46
|
qualityLimitedBy?: string;
|
|
47
|
+
/** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
|
|
48
|
+
trackMode?: TrackMode;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
function buildCamera(init: MockCameraInit): CameraState {
|
|
43
52
|
return {
|
|
44
53
|
flightId: init.flightId,
|
|
45
54
|
lifecycle: init.lifecycle ?? CameraLifecycle.Active,
|
|
55
|
+
kind: init.kind ?? CameraKind.Part,
|
|
56
|
+
crewLocation: init.crewLocation,
|
|
57
|
+
kerbalPersistentId: init.kerbalPersistentId,
|
|
46
58
|
partName: init.partName ?? `part-${init.flightId}`,
|
|
47
59
|
partTitle: init.partTitle ?? `Part ${init.flightId}`,
|
|
48
60
|
cameraName: init.cameraName ?? `camera-${init.flightId}`,
|
|
@@ -69,6 +81,7 @@ function buildCamera(init: MockCameraInit): CameraState {
|
|
|
69
81
|
degradeLevel: init.degradeLevel ?? 0,
|
|
70
82
|
viewerQuality: init.viewerQuality,
|
|
71
83
|
qualityLimitedBy: init.qualityLimitedBy,
|
|
84
|
+
trackMode: init.trackMode ?? TrackMode.None,
|
|
72
85
|
};
|
|
73
86
|
}
|
|
74
87
|
|
|
@@ -120,6 +133,9 @@ export class MockSidecar {
|
|
|
120
133
|
private _onTrackHandler:
|
|
121
134
|
| ((track: MediaStreamTrack, idx: number, mid: string) => void)
|
|
122
135
|
| undefined;
|
|
136
|
+
private _subscribeHandler:
|
|
137
|
+
| ((flightId: number, mid: string) => void)
|
|
138
|
+
| undefined;
|
|
123
139
|
private _trackIdx = 0;
|
|
124
140
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
125
141
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -295,6 +311,16 @@ export class MockSidecar {
|
|
|
295
311
|
this._onTrackHandler?.(track, this._trackIdx++, mid);
|
|
296
312
|
}
|
|
297
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
316
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
317
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
318
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
319
|
+
*/
|
|
320
|
+
onSubscribe(handler: (flightId: number, mid: string) => void): void {
|
|
321
|
+
this._subscribeHandler = handler;
|
|
322
|
+
}
|
|
323
|
+
|
|
298
324
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
299
325
|
slotMidFor(flightId: number): string | undefined {
|
|
300
326
|
for (const [mid, fid] of this._slotBindings) {
|
|
@@ -520,6 +546,18 @@ export class MockSidecar {
|
|
|
520
546
|
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
521
547
|
break;
|
|
522
548
|
}
|
|
549
|
+
case "set-track-target": {
|
|
550
|
+
// Server-authoritative: hold the chosen mode and broadcast it back as
|
|
551
|
+
// camera-state-changed, mirroring the sidecar so every browser reflects
|
|
552
|
+
// the same trackMode (never optimistic-local).
|
|
553
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
554
|
+
if (cam) {
|
|
555
|
+
const updated: CameraState = { ...cam, trackMode: msg.content.mode };
|
|
556
|
+
this._cameras.set(msg.content.flightId, updated);
|
|
557
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
523
561
|
case "subscribe": {
|
|
524
562
|
const flightId = msg.content.flightId;
|
|
525
563
|
const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
|
|
@@ -534,6 +572,9 @@ export class MockSidecar {
|
|
|
534
572
|
type: "slot-map",
|
|
535
573
|
content: { mid: freeMid, flightId },
|
|
536
574
|
});
|
|
575
|
+
// Let a harness deliver this camera's track to the slot it was just
|
|
576
|
+
// bound to (so tracks follow the actual subscription, not array order).
|
|
577
|
+
this._subscribeHandler?.(flightId, freeMid);
|
|
537
578
|
}
|
|
538
579
|
break;
|
|
539
580
|
}
|
|
@@ -559,6 +600,11 @@ export class MockSidecar {
|
|
|
559
600
|
// the mock has no frame clock, so it doesn't model their *effect* on
|
|
560
601
|
// panYaw/fov. They're still recorded in `_commands`, so consumer tests
|
|
561
602
|
// can assert the command was sent via `lastCommand("set-pan-rate")`.
|
|
603
|
+
// Advisory per-consumer display-size input. The real sidecar aggregates
|
|
604
|
+
// it MAX-across-consumers to drive auto-resolution; the mock has no
|
|
605
|
+
// aggregator, so it just records the command (via `_commands`) and does
|
|
606
|
+
// NOT mutate camera render dims (unlike the operator `set-render-size`).
|
|
607
|
+
case "report-display-size":
|
|
562
608
|
case "set-pan-rate":
|
|
563
609
|
case "set-zoom-rate":
|
|
564
610
|
case "hello":
|
package/src/testing.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { CameraLifecycle, Layer } from "./__generated__/types";
|
|
2
|
+
import { CameraLifecycle, Layer, TrackMode } from "./__generated__/types";
|
|
3
3
|
import { KerbcastClient } from "./index";
|
|
4
4
|
import { MockSidecar } from "./testing/index";
|
|
5
5
|
|
|
@@ -121,6 +121,48 @@ describe("MockSidecar", () => {
|
|
|
121
121
|
expect(last?.content.fov).toBe(60);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("accepts and records report-display-size as an advisory command", async () => {
|
|
125
|
+
const sidecar = new MockSidecar();
|
|
126
|
+
sidecar.addCamera({ flightId: 42 });
|
|
127
|
+
|
|
128
|
+
const client = new KerbcastClient(
|
|
129
|
+
{ host: "localhost", port: 8088 },
|
|
130
|
+
sidecar.createTransport(),
|
|
131
|
+
);
|
|
132
|
+
await client.connect([42]);
|
|
133
|
+
sidecar.open();
|
|
134
|
+
|
|
135
|
+
// Must not throw on the new message type, and must be retrievable.
|
|
136
|
+
await client.reportDisplaySize(42, 40, 40);
|
|
137
|
+
|
|
138
|
+
const cmd = sidecar.lastCommand("report-display-size", 42);
|
|
139
|
+
expect(cmd?.content).toEqual({ flightId: 42, width: 40, height: 40 });
|
|
140
|
+
// Advisory: reporting a display size must NOT be translated into an
|
|
141
|
+
// operator set-render-size command (that path is the manual cap only).
|
|
142
|
+
expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("set-track-target updates trackMode and echoes it to the client (server-authoritative)", async () => {
|
|
146
|
+
const sidecar = new MockSidecar();
|
|
147
|
+
sidecar.addCamera({ flightId: 7, supportsPan: true, supportsZoom: true });
|
|
148
|
+
|
|
149
|
+
const client = new KerbcastClient(
|
|
150
|
+
{ host: "localhost", port: 8088 },
|
|
151
|
+
sidecar.createTransport(),
|
|
152
|
+
);
|
|
153
|
+
await client.connect([7]);
|
|
154
|
+
sidecar.open();
|
|
155
|
+
|
|
156
|
+
await client.setTrackTarget(7, TrackMode.Target);
|
|
157
|
+
expect(sidecar.lastCommand("set-track-target", 7)?.content.mode).toBe(TrackMode.Target);
|
|
158
|
+
// The mock echoes camera-state-changed so every client reflects the same
|
|
159
|
+
// server-held mode (never optimistic-local).
|
|
160
|
+
expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.Target);
|
|
161
|
+
|
|
162
|
+
await client.setTrackTarget(7, TrackMode.None);
|
|
163
|
+
expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.None);
|
|
164
|
+
});
|
|
165
|
+
|
|
124
166
|
it("updateCamera pushes state-change to the client", async () => {
|
|
125
167
|
const sidecar = new MockSidecar();
|
|
126
168
|
sidecar.addCamera({ flightId: 42, layers: [Layer.Near] });
|