@ksp-gonogo/kerbcast 1.6.2 → 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 +120 -13
- package/dist/__generated__/types.js +31 -0
- package/dist/client.d.ts +18 -1
- package/dist/client.js +54 -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 +119 -13
- package/src/client.test.ts +39 -0
- package/src/client.ts +60 -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;
|
|
@@ -131,8 +182,8 @@ export interface CameraState {
|
|
|
131
182
|
*/
|
|
132
183
|
targetBitrateBps: number;
|
|
133
184
|
/**
|
|
134
|
-
* Effective degrade level (
|
|
135
|
-
* requests). 0.0 = perfect, 1.0 = max degradation. Applied
|
|
185
|
+
* Effective degrade level (last-writer-wins across subscribers'
|
|
186
|
+
* SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
|
|
136
187
|
* alongside operator render-size + adaptive shed; the encoder
|
|
137
188
|
* multiplies its effective bitrate by `(1 - 0.7 * level)` and
|
|
138
189
|
* skips fan-out for a fraction of frames at high levels.
|
|
@@ -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,
|
|
@@ -187,10 +251,12 @@ export interface SceneStateChangedPayload {
|
|
|
187
251
|
export interface SetDegradePayload {
|
|
188
252
|
flightId: number;
|
|
189
253
|
/**
|
|
190
|
-
* 0.0 = perfect quality, 1.0 = maximum degradation.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
254
|
+
* 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
|
|
255
|
+
* Last-writer-wins: degrade is a property of the camera's game
|
|
256
|
+
* state (comms strength), shared by all viewers of the single
|
|
257
|
+
* encoded stream, so the sidecar stores the latest value rather
|
|
258
|
+
* than reducing across subscribers. A well-behaved driver
|
|
259
|
+
* re-asserts every tick.
|
|
194
260
|
*/
|
|
195
261
|
level: number;
|
|
196
262
|
}
|
|
@@ -246,6 +312,21 @@ export interface SetThrottleMainScreenPayload {
|
|
|
246
312
|
*/
|
|
247
313
|
enabled: boolean;
|
|
248
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
|
+
}
|
|
249
330
|
export interface SetZoomRatePayload {
|
|
250
331
|
flightId: number;
|
|
251
332
|
/**
|
|
@@ -322,13 +403,38 @@ export type ClientMessage =
|
|
|
322
403
|
content: SetLayersPayload;
|
|
323
404
|
}
|
|
324
405
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
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.
|
|
327
411
|
*/
|
|
328
412
|
| {
|
|
329
413
|
type: "set-render-size";
|
|
330
414
|
content: SetRenderSizePayload;
|
|
331
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
|
+
}
|
|
332
438
|
/**
|
|
333
439
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
334
440
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
|
@@ -379,11 +485,12 @@ export type ClientMessage =
|
|
|
379
485
|
}
|
|
380
486
|
/**
|
|
381
487
|
* Request artificial signal degradation. 0.0 = perfect quality,
|
|
382
|
-
* 1.0 = maximum degradation.
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
* to the sidecar "I want to look
|
|
386
|
-
* antenna is struggling"; sidecar takes
|
|
488
|
+
* 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
|
|
489
|
+
* the latest value on the camera (degrade is shared across all
|
|
490
|
+
* viewers of the one encoded stream), so a departed peer can't
|
|
491
|
+
* pin it. Lets consumers signal to the sidecar "I want to look
|
|
492
|
+
* like the in-game CommNet antenna is struggling"; sidecar takes
|
|
493
|
+
* the opportunity to
|
|
387
494
|
* reduce bitrate + skip frames, which both creates the
|
|
388
495
|
* signal-loss aesthetic AND saves encoder CPU.
|
|
389
496
|
*/
|
|
@@ -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
|
@@ -4,6 +4,13 @@ import { tryCreateNoisePipeline } from "./noise";
|
|
|
4
4
|
const SOURCELESS_INTENSITY = 1.0;
|
|
5
5
|
/** Floor applied to the degrade-driven intensity of a live feed. */
|
|
6
6
|
const LIVE_INTENSITY_FLOOR = 0.05;
|
|
7
|
+
/**
|
|
8
|
+
* While a feed is stalled, how often to re-request a keyframe. A stall is
|
|
9
|
+
* usually a wedged decoder that only a fresh IDR clears; the first request or
|
|
10
|
+
* its keyframe can be lost during the same outage, so we keep asking until
|
|
11
|
+
* frames resume.
|
|
12
|
+
*/
|
|
13
|
+
const STALL_KEYFRAME_RETRY_MS = 2000;
|
|
7
14
|
export class BrowserKerbcastTransport {
|
|
8
15
|
constructor(opts = {}) {
|
|
9
16
|
this.opts = { iceGatheringTimeoutMs: opts.iceGatheringTimeoutMs ?? 2000 };
|
|
@@ -159,6 +166,8 @@ class CameraHandle extends TypedEmitter {
|
|
|
159
166
|
this._sourceless = false;
|
|
160
167
|
/** Stall state mirrored from the pipeline's onStallChange callback. */
|
|
161
168
|
this._stalled = false;
|
|
169
|
+
/** Interval that re-requests keyframes while stalled; null when not stalled. */
|
|
170
|
+
this._keyframeRetry = null;
|
|
162
171
|
/** Whether animated static is drawn; survives pipeline rebuilds. */
|
|
163
172
|
this._showStatic = true;
|
|
164
173
|
this.flightId = flightId;
|
|
@@ -291,6 +300,26 @@ class CameraHandle extends TypedEmitter {
|
|
|
291
300
|
return;
|
|
292
301
|
this._stalled = stalled;
|
|
293
302
|
this.emit("stall", stalled);
|
|
303
|
+
if (stalled) {
|
|
304
|
+
// A stall usually means the browser decoder is wedged (broken H.264
|
|
305
|
+
// reference chain after packet loss or heavy degrade), which only a
|
|
306
|
+
// fresh keyframe clears. Force one now and keep asking while stalled,
|
|
307
|
+
// rather than waiting on the browser's rate-limited PLI.
|
|
308
|
+
this._requestKeyframeForRecovery();
|
|
309
|
+
if (this._keyframeRetry === null) {
|
|
310
|
+
this._keyframeRetry = setInterval(() => this._requestKeyframeForRecovery(), STALL_KEYFRAME_RETRY_MS);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (this._keyframeRetry !== null) {
|
|
314
|
+
clearInterval(this._keyframeRetry);
|
|
315
|
+
this._keyframeRetry = null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
_requestKeyframeForRecovery() {
|
|
319
|
+
this.requestKeyframe().catch(() => {
|
|
320
|
+
// Best effort; a failed send (disconnected) is retried on the next tick,
|
|
321
|
+
// or cleared when the stall ends.
|
|
322
|
+
});
|
|
294
323
|
}
|
|
295
324
|
_liveIntensity() {
|
|
296
325
|
return Math.max(LIVE_INTENSITY_FLOOR, this._state?.degradeLevel ?? 0);
|
|
@@ -300,6 +329,10 @@ class CameraHandle extends TypedEmitter {
|
|
|
300
329
|
* the persistent pipeline, stops its rAF loop, and nulls the stream.
|
|
301
330
|
*/
|
|
302
331
|
_teardown() {
|
|
332
|
+
if (this._keyframeRetry !== null) {
|
|
333
|
+
clearInterval(this._keyframeRetry);
|
|
334
|
+
this._keyframeRetry = null;
|
|
335
|
+
}
|
|
303
336
|
this._noisePipeline?.destroy();
|
|
304
337
|
this._noisePipeline = null;
|
|
305
338
|
this._rawStream = null;
|
|
@@ -625,6 +658,27 @@ export class KerbcastClient extends TypedEmitter {
|
|
|
625
658
|
async unsubscribe(flightId) {
|
|
626
659
|
await this._send({ type: "unsubscribe", content: { flightId } });
|
|
627
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
|
+
}
|
|
628
682
|
/**
|
|
629
683
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
630
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;
|
|
@@ -139,8 +193,8 @@ export interface CameraState {
|
|
|
139
193
|
*/
|
|
140
194
|
targetBitrateBps: number;
|
|
141
195
|
/**
|
|
142
|
-
* Effective degrade level (
|
|
143
|
-
* requests). 0.0 = perfect, 1.0 = max degradation. Applied
|
|
196
|
+
* Effective degrade level (last-writer-wins across subscribers'
|
|
197
|
+
* SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
|
|
144
198
|
* alongside operator render-size + adaptive shed; the encoder
|
|
145
199
|
* multiplies its effective bitrate by `(1 - 0.7 * level)` and
|
|
146
200
|
* skips fan-out for a fraction of frames at high levels.
|
|
@@ -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,
|
|
@@ -203,10 +271,12 @@ export interface SceneStateChangedPayload {
|
|
|
203
271
|
export interface SetDegradePayload {
|
|
204
272
|
flightId: number;
|
|
205
273
|
/**
|
|
206
|
-
* 0.0 = perfect quality, 1.0 = maximum degradation.
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
274
|
+
* 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
|
|
275
|
+
* Last-writer-wins: degrade is a property of the camera's game
|
|
276
|
+
* state (comms strength), shared by all viewers of the single
|
|
277
|
+
* encoded stream, so the sidecar stores the latest value rather
|
|
278
|
+
* than reducing across subscribers. A well-behaved driver
|
|
279
|
+
* re-asserts every tick.
|
|
210
280
|
*/
|
|
211
281
|
level: number;
|
|
212
282
|
}
|
|
@@ -270,6 +340,22 @@ export interface SetThrottleMainScreenPayload {
|
|
|
270
340
|
enabled: boolean;
|
|
271
341
|
}
|
|
272
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
|
+
|
|
273
359
|
export interface SetZoomRatePayload {
|
|
274
360
|
flightId: number;
|
|
275
361
|
/**
|
|
@@ -343,10 +429,29 @@ export type ClientMessage =
|
|
|
343
429
|
*/
|
|
344
430
|
| { type: "set-layers", content: SetLayersPayload }
|
|
345
431
|
/**
|
|
346
|
-
*
|
|
347
|
-
*
|
|
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.
|
|
348
437
|
*/
|
|
349
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 }
|
|
350
455
|
/**
|
|
351
456
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
352
457
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
|
@@ -385,11 +490,12 @@ export type ClientMessage =
|
|
|
385
490
|
| { type: "set-zoom-rate", content: SetZoomRatePayload }
|
|
386
491
|
/**
|
|
387
492
|
* Request artificial signal degradation. 0.0 = perfect quality,
|
|
388
|
-
* 1.0 = maximum degradation.
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
* to the sidecar "I want to look
|
|
392
|
-
* antenna is struggling"; sidecar takes
|
|
493
|
+
* 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
|
|
494
|
+
* the latest value on the camera (degrade is shared across all
|
|
495
|
+
* viewers of the one encoded stream), so a departed peer can't
|
|
496
|
+
* pin it. Lets consumers signal to the sidecar "I want to look
|
|
497
|
+
* like the in-game CommNet antenna is struggling"; sidecar takes
|
|
498
|
+
* the opportunity to
|
|
393
499
|
* reduce bitrate + skip frames, which both creates the
|
|
394
500
|
* signal-loss aesthetic AND saves encoder CPU.
|
|
395
501
|
*/
|
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";
|
|
@@ -15,6 +16,13 @@ import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
|
|
|
15
16
|
const SOURCELESS_INTENSITY = 1.0;
|
|
16
17
|
/** Floor applied to the degrade-driven intensity of a live feed. */
|
|
17
18
|
const LIVE_INTENSITY_FLOOR = 0.05;
|
|
19
|
+
/**
|
|
20
|
+
* While a feed is stalled, how often to re-request a keyframe. A stall is
|
|
21
|
+
* usually a wedged decoder that only a fresh IDR clears; the first request or
|
|
22
|
+
* its keyframe can be lost during the same outage, so we keep asking until
|
|
23
|
+
* frames resume.
|
|
24
|
+
*/
|
|
25
|
+
const STALL_KEYFRAME_RETRY_MS = 2000;
|
|
18
26
|
|
|
19
27
|
/** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
|
|
20
28
|
export interface NoiseConfig {
|
|
@@ -473,6 +481,8 @@ class CameraHandle
|
|
|
473
481
|
private _sourceless = false;
|
|
474
482
|
/** Stall state mirrored from the pipeline's onStallChange callback. */
|
|
475
483
|
private _stalled = false;
|
|
484
|
+
/** Interval that re-requests keyframes while stalled; null when not stalled. */
|
|
485
|
+
private _keyframeRetry: ReturnType<typeof setInterval> | null = null;
|
|
476
486
|
/** Whether animated static is drawn; survives pipeline rebuilds. */
|
|
477
487
|
private _showStatic = true;
|
|
478
488
|
private readonly client: KerbcastClient;
|
|
@@ -625,6 +635,29 @@ class CameraHandle
|
|
|
625
635
|
if (this._stalled === stalled) return;
|
|
626
636
|
this._stalled = stalled;
|
|
627
637
|
this.emit("stall", stalled);
|
|
638
|
+
if (stalled) {
|
|
639
|
+
// A stall usually means the browser decoder is wedged (broken H.264
|
|
640
|
+
// reference chain after packet loss or heavy degrade), which only a
|
|
641
|
+
// fresh keyframe clears. Force one now and keep asking while stalled,
|
|
642
|
+
// rather than waiting on the browser's rate-limited PLI.
|
|
643
|
+
this._requestKeyframeForRecovery();
|
|
644
|
+
if (this._keyframeRetry === null) {
|
|
645
|
+
this._keyframeRetry = setInterval(
|
|
646
|
+
() => this._requestKeyframeForRecovery(),
|
|
647
|
+
STALL_KEYFRAME_RETRY_MS,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
} else if (this._keyframeRetry !== null) {
|
|
651
|
+
clearInterval(this._keyframeRetry);
|
|
652
|
+
this._keyframeRetry = null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private _requestKeyframeForRecovery(): void {
|
|
657
|
+
this.requestKeyframe().catch(() => {
|
|
658
|
+
// Best effort; a failed send (disconnected) is retried on the next tick,
|
|
659
|
+
// or cleared when the stall ends.
|
|
660
|
+
});
|
|
628
661
|
}
|
|
629
662
|
|
|
630
663
|
private _liveIntensity(): number {
|
|
@@ -636,6 +669,10 @@ class CameraHandle
|
|
|
636
669
|
* the persistent pipeline, stops its rAF loop, and nulls the stream.
|
|
637
670
|
*/
|
|
638
671
|
_teardown(): void {
|
|
672
|
+
if (this._keyframeRetry !== null) {
|
|
673
|
+
clearInterval(this._keyframeRetry);
|
|
674
|
+
this._keyframeRetry = null;
|
|
675
|
+
}
|
|
639
676
|
this._noisePipeline?.destroy();
|
|
640
677
|
this._noisePipeline = null;
|
|
641
678
|
this._rawStream = null;
|
|
@@ -1004,6 +1041,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
1004
1041
|
await this._send({ type: "unsubscribe", content: { flightId } });
|
|
1005
1042
|
}
|
|
1006
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
|
+
|
|
1007
1067
|
/**
|
|
1008
1068
|
* Internal — resolves the effective noise enabled state for a camera,
|
|
1009
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] });
|