@ksp-gonogo/kerbcast 1.6.3 → 1.8.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 +61 -0
- package/README.md +16 -0
- package/dist/__generated__/types.d.ts +129 -2
- package/dist/__generated__/types.js +31 -0
- package/dist/client.d.ts +74 -1
- package/dist/client.js +64 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recording.d.ts +426 -0
- package/dist/recording.js +898 -0
- package/dist/testing/index.d.ts +106 -2
- package/dist/testing/index.js +234 -4
- package/package.json +4 -1
- package/src/__generated__/types.ts +126 -2
- package/src/client.test.ts +63 -0
- package/src/client.ts +98 -0
- package/src/index.ts +22 -0
- package/src/recording.test.ts +1471 -0
- package/src/recording.ts +1233 -0
- package/src/testing/index.ts +286 -2
- package/src/testing.test.ts +190 -2
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";
|
|
3
|
-
import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
|
|
2
|
+
import { CameraKind, CameraLifecycle, CrewLocation, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
3
|
+
import type { DiscoveredCamera, 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,29 @@ 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;
|
|
42
|
+
/**
|
|
43
|
+
* Physical/ceiling render size, served by the mock's `/cameras` discovery
|
|
44
|
+
* intercept (`DiscoveredCamera.maxWidth`/`maxHeight`). Defaults to
|
|
45
|
+
* `operatorWidth`/`operatorHeight` (i.e. already at ceiling) so a camera
|
|
46
|
+
* that never sets these behaves as it always has. Give a camera a ceiling
|
|
47
|
+
* above its initial `operatorWidth`/`operatorHeight` to demonstrate a
|
|
48
|
+
* visible bump when `set-force-full-resolution` lands.
|
|
49
|
+
*/
|
|
50
|
+
maxWidth?: number;
|
|
51
|
+
maxHeight?: number;
|
|
52
|
+
}
|
|
53
|
+
/** Options for {@link MockSidecar}'s constructor. */
|
|
54
|
+
export interface MockSidecarOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Delay (ms) before a forced camera's simulated resolution bump lands,
|
|
57
|
+
* once `set-force-full-resolution` (`force: true`) is received, and
|
|
58
|
+
* before the render size reverts on `force: false`. Long enough that a
|
|
59
|
+
* screenshot can catch the resulting "ARMING" state; short enough that
|
|
60
|
+
* automated tests/waits don't feel slow. Defaults to 600ms.
|
|
61
|
+
*/
|
|
62
|
+
forceBumpDelayMs?: number;
|
|
33
63
|
}
|
|
34
64
|
/**
|
|
35
65
|
* In-process protocol-level fake for the kerbcast sidecar.
|
|
@@ -57,10 +87,19 @@ export declare class MockSidecar {
|
|
|
57
87
|
private readonly _cameras;
|
|
58
88
|
private readonly _commands;
|
|
59
89
|
private _throttleMainScreen;
|
|
90
|
+
private readonly _forceBumpDelayMs;
|
|
91
|
+
/** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
|
|
92
|
+
private readonly _maxRenderSizes;
|
|
93
|
+
/** Per-flight render size captured just before a force bump, restored on release. */
|
|
94
|
+
private readonly _preForceSize;
|
|
95
|
+
/** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
|
|
96
|
+
private readonly _forceTimers;
|
|
97
|
+
constructor(opts?: MockSidecarOptions);
|
|
60
98
|
private _openHandler;
|
|
61
99
|
private _clientMsgHandler;
|
|
62
100
|
private _stateHandler;
|
|
63
101
|
private _onTrackHandler;
|
|
102
|
+
private _subscribeHandler;
|
|
64
103
|
private _trackIdx;
|
|
65
104
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
66
105
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -79,6 +118,14 @@ export declare class MockSidecar {
|
|
|
79
118
|
private readonly _deliveredTracks;
|
|
80
119
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
81
120
|
addCamera(init: MockCameraInit): void;
|
|
121
|
+
/**
|
|
122
|
+
* The `DiscoveredCamera[]` the mock's `GET /cameras` intercept serves,
|
|
123
|
+
* mirroring the real sidecar's discovery endpoint. `maxWidth`/`maxHeight`
|
|
124
|
+
* come from each camera's `maxWidth`/`maxHeight` init (or its initial
|
|
125
|
+
* `operatorWidth`/`operatorHeight` when omitted). Used by
|
|
126
|
+
* `client.discover()` to populate `KerbcastCameraHandle.maxRenderSize`.
|
|
127
|
+
*/
|
|
128
|
+
discoveredCameras(): DiscoveredCamera[];
|
|
82
129
|
/**
|
|
83
130
|
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
84
131
|
* second argument to `KerbcastClient`.
|
|
@@ -134,6 +181,13 @@ export declare class MockSidecar {
|
|
|
134
181
|
* `trackIdentifier` matching what the client received.
|
|
135
182
|
*/
|
|
136
183
|
deliverTrack(mid: string, track: MediaStreamTrack): void;
|
|
184
|
+
/**
|
|
185
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
186
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
187
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
188
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
189
|
+
*/
|
|
190
|
+
onSubscribe(handler: (flightId: number, mid: string) => void): void;
|
|
137
191
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
138
192
|
slotMidFor(flightId: number): string | undefined;
|
|
139
193
|
/** Every `ClientMessage` received from the client, in order. */
|
|
@@ -192,6 +246,18 @@ export declare class MockSidecar {
|
|
|
192
246
|
private _buildStatsReport;
|
|
193
247
|
private _sendToClient;
|
|
194
248
|
private _handleClientMessage;
|
|
249
|
+
/**
|
|
250
|
+
* Simulate the sidecar's `effective_render_size` force branch: after
|
|
251
|
+
* {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
|
|
252
|
+
* ceiling (`force: true`) or restore whatever it was before the force
|
|
253
|
+
* (`force: false`), pushing a `camera-state-changed` either way. The delay
|
|
254
|
+
* models the real round trip (control-file write, plugin capture pickup,
|
|
255
|
+
* WebRTC renegotiation) and gives a UI something to show an "ARMING" state
|
|
256
|
+
* for. A rapid re-toggle cancels the previous pending bump/revert.
|
|
257
|
+
*/
|
|
258
|
+
private _setForced;
|
|
259
|
+
/** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
|
|
260
|
+
private _applyRenderSize;
|
|
195
261
|
}
|
|
196
262
|
/**
|
|
197
263
|
* Install jsdom shims needed by kerbcast component tests.
|
|
@@ -217,3 +283,41 @@ export declare class MockSidecar {
|
|
|
217
283
|
* detection reads prefers-color-scheme through it.
|
|
218
284
|
*/
|
|
219
285
|
export declare function installDomStubs(): void;
|
|
286
|
+
/**
|
|
287
|
+
* Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
|
|
288
|
+
*
|
|
289
|
+
* jsdom implements no MediaRecorder, so the recording controller can't run
|
|
290
|
+
* headlessly without this. On `stop()` it synchronously flushes one canned
|
|
291
|
+
* Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
|
|
292
|
+
* `stop`, closing the start -> stop -> Blob path the controller drives.
|
|
293
|
+
*
|
|
294
|
+
* `isTypeSupported` is controllable so tests exercise mime negotiation:
|
|
295
|
+
* - `supportedTypes = null` (default): support everything, so negotiation
|
|
296
|
+
* picks the mp4 preference.
|
|
297
|
+
* - `supportedTypes = ["video/webm"]`: force the webm fallback.
|
|
298
|
+
* Reset `supportedTypes` back to null between tests that change it.
|
|
299
|
+
*/
|
|
300
|
+
export declare class StubMediaRecorder {
|
|
301
|
+
/** Types isTypeSupported reports available; null = everything. */
|
|
302
|
+
static supportedTypes: string[] | null;
|
|
303
|
+
static isTypeSupported(type: string): boolean;
|
|
304
|
+
readonly stream: MediaStream;
|
|
305
|
+
readonly mimeType: string;
|
|
306
|
+
state: "inactive" | "recording" | "paused";
|
|
307
|
+
private readonly _listeners;
|
|
308
|
+
constructor(stream: MediaStream, options?: {
|
|
309
|
+
mimeType?: string;
|
|
310
|
+
});
|
|
311
|
+
addEventListener(type: string, cb: (e: {
|
|
312
|
+
data?: Blob;
|
|
313
|
+
}) => void): void;
|
|
314
|
+
removeEventListener(type: string, cb: (e: {
|
|
315
|
+
data?: Blob;
|
|
316
|
+
}) => void): void;
|
|
317
|
+
private _emit;
|
|
318
|
+
start(_timeslice?: number): void;
|
|
319
|
+
/** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
|
|
320
|
+
stop(): void;
|
|
321
|
+
/** Emit a partial data chunk without stopping (matches the real API). */
|
|
322
|
+
requestData(): void;
|
|
323
|
+
}
|
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
|
|
@@ -67,10 +71,16 @@ function scaleDim(operatorDim, scale) {
|
|
|
67
71
|
* ```
|
|
68
72
|
*/
|
|
69
73
|
export class MockSidecar {
|
|
70
|
-
constructor() {
|
|
74
|
+
constructor(opts = {}) {
|
|
71
75
|
this._cameras = new Map();
|
|
72
76
|
this._commands = [];
|
|
73
77
|
this._throttleMainScreen = false;
|
|
78
|
+
/** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
|
|
79
|
+
this._maxRenderSizes = new Map();
|
|
80
|
+
/** Per-flight render size captured just before a force bump, restored on release. */
|
|
81
|
+
this._preForceSize = new Map();
|
|
82
|
+
/** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
|
|
83
|
+
this._forceTimers = new Map();
|
|
74
84
|
this._trackIdx = 0;
|
|
75
85
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
76
86
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -87,10 +97,50 @@ export class MockSidecar {
|
|
|
87
97
|
* getStats can synthesize a trackIdentifier matching what the client saw.
|
|
88
98
|
*/
|
|
89
99
|
this._deliveredTracks = new Map();
|
|
100
|
+
this._forceBumpDelayMs = opts.forceBumpDelayMs ?? 600;
|
|
90
101
|
}
|
|
91
102
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
92
103
|
addCamera(init) {
|
|
93
|
-
|
|
104
|
+
const cam = buildCamera(init);
|
|
105
|
+
this._cameras.set(init.flightId, cam);
|
|
106
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
107
|
+
maxWidth: init.maxWidth ?? cam.operatorWidth,
|
|
108
|
+
maxHeight: init.maxHeight ?? cam.operatorHeight,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The `DiscoveredCamera[]` the mock's `GET /cameras` intercept serves,
|
|
113
|
+
* mirroring the real sidecar's discovery endpoint. `maxWidth`/`maxHeight`
|
|
114
|
+
* come from each camera's `maxWidth`/`maxHeight` init (or its initial
|
|
115
|
+
* `operatorWidth`/`operatorHeight` when omitted). Used by
|
|
116
|
+
* `client.discover()` to populate `KerbcastCameraHandle.maxRenderSize`.
|
|
117
|
+
*/
|
|
118
|
+
discoveredCameras() {
|
|
119
|
+
return Array.from(this._cameras.values()).map((cam) => {
|
|
120
|
+
const max = this._maxRenderSizes.get(cam.flightId);
|
|
121
|
+
return {
|
|
122
|
+
flightId: cam.flightId,
|
|
123
|
+
lifecycle: cam.lifecycle,
|
|
124
|
+
partName: cam.partName,
|
|
125
|
+
partTitle: cam.partTitle,
|
|
126
|
+
cameraName: cam.cameraName,
|
|
127
|
+
vesselName: cam.vesselName,
|
|
128
|
+
maxWidth: max?.maxWidth ?? cam.operatorWidth,
|
|
129
|
+
maxHeight: max?.maxHeight ?? cam.operatorHeight,
|
|
130
|
+
supportsZoom: cam.supportsZoom,
|
|
131
|
+
fov: cam.fov,
|
|
132
|
+
fovMin: cam.fovMin,
|
|
133
|
+
fovMax: cam.fovMax,
|
|
134
|
+
supportsPan: cam.supportsPan,
|
|
135
|
+
panYawMin: cam.panYawMin,
|
|
136
|
+
panYawMax: cam.panYawMax,
|
|
137
|
+
panPitchMin: cam.panPitchMin,
|
|
138
|
+
panPitchMax: cam.panPitchMax,
|
|
139
|
+
encoderBitrateBps: cam.encoderBitrateBps,
|
|
140
|
+
targetBitrateBps: cam.targetBitrateBps,
|
|
141
|
+
degradeLevel: cam.degradeLevel,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
94
144
|
}
|
|
95
145
|
/**
|
|
96
146
|
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
@@ -181,9 +231,18 @@ export class MockSidecar {
|
|
|
181
231
|
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
182
232
|
*/
|
|
183
233
|
setCameras(inits) {
|
|
234
|
+
for (const timer of this._forceTimers.values())
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
this._forceTimers.clear();
|
|
237
|
+
this._preForceSize.clear();
|
|
184
238
|
this._cameras.clear();
|
|
239
|
+
this._maxRenderSizes.clear();
|
|
185
240
|
for (const init of inits) {
|
|
186
241
|
this._cameras.set(init.flightId, buildCamera(init));
|
|
242
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
243
|
+
maxWidth: init.maxWidth ?? init.operatorWidth ?? 1280,
|
|
244
|
+
maxHeight: init.maxHeight ?? init.operatorHeight ?? 720,
|
|
245
|
+
});
|
|
187
246
|
}
|
|
188
247
|
this._sendToClient({
|
|
189
248
|
type: "camera-snapshot",
|
|
@@ -233,6 +292,15 @@ export class MockSidecar {
|
|
|
233
292
|
this._deliveredTracks.set(mid, track);
|
|
234
293
|
this._onTrackHandler?.(track, this._trackIdx++, mid);
|
|
235
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
297
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
298
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
299
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
300
|
+
*/
|
|
301
|
+
onSubscribe(handler) {
|
|
302
|
+
this._subscribeHandler = handler;
|
|
303
|
+
}
|
|
236
304
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
237
305
|
slotMidFor(flightId) {
|
|
238
306
|
for (const [mid, fid] of this._slotBindings) {
|
|
@@ -439,6 +507,18 @@ export class MockSidecar {
|
|
|
439
507
|
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
440
508
|
break;
|
|
441
509
|
}
|
|
510
|
+
case "set-track-target": {
|
|
511
|
+
// Server-authoritative: hold the chosen mode and broadcast it back as
|
|
512
|
+
// camera-state-changed, mirroring the sidecar so every browser reflects
|
|
513
|
+
// the same trackMode (never optimistic-local).
|
|
514
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
515
|
+
if (cam) {
|
|
516
|
+
const updated = { ...cam, trackMode: msg.content.mode };
|
|
517
|
+
this._cameras.set(msg.content.flightId, updated);
|
|
518
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
519
|
+
}
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
442
522
|
case "subscribe": {
|
|
443
523
|
const flightId = msg.content.flightId;
|
|
444
524
|
const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
|
|
@@ -454,6 +534,9 @@ export class MockSidecar {
|
|
|
454
534
|
type: "slot-map",
|
|
455
535
|
content: { mid: freeMid, flightId },
|
|
456
536
|
});
|
|
537
|
+
// Let a harness deliver this camera's track to the slot it was just
|
|
538
|
+
// bound to (so tracks follow the actual subscription, not array order).
|
|
539
|
+
this._subscribeHandler?.(flightId, freeMid);
|
|
457
540
|
}
|
|
458
541
|
break;
|
|
459
542
|
}
|
|
@@ -479,6 +562,11 @@ export class MockSidecar {
|
|
|
479
562
|
// the mock has no frame clock, so it doesn't model their *effect* on
|
|
480
563
|
// panYaw/fov. They're still recorded in `_commands`, so consumer tests
|
|
481
564
|
// can assert the command was sent via `lastCommand("set-pan-rate")`.
|
|
565
|
+
// Advisory per-consumer display-size input. The real sidecar aggregates
|
|
566
|
+
// it MAX-across-consumers to drive auto-resolution; the mock has no
|
|
567
|
+
// aggregator, so it just records the command (via `_commands`) and does
|
|
568
|
+
// NOT mutate camera render dims (unlike the operator `set-render-size`).
|
|
569
|
+
case "report-display-size":
|
|
482
570
|
case "set-pan-rate":
|
|
483
571
|
case "set-zoom-rate":
|
|
484
572
|
case "hello":
|
|
@@ -494,10 +582,70 @@ export class MockSidecar {
|
|
|
494
582
|
});
|
|
495
583
|
break;
|
|
496
584
|
}
|
|
585
|
+
case "set-force-full-resolution": {
|
|
586
|
+
this._setForced(msg.content.flightId, msg.content.force);
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
497
589
|
case "disconnect":
|
|
498
590
|
break;
|
|
499
591
|
}
|
|
500
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Simulate the sidecar's `effective_render_size` force branch: after
|
|
595
|
+
* {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
|
|
596
|
+
* ceiling (`force: true`) or restore whatever it was before the force
|
|
597
|
+
* (`force: false`), pushing a `camera-state-changed` either way. The delay
|
|
598
|
+
* models the real round trip (control-file write, plugin capture pickup,
|
|
599
|
+
* WebRTC renegotiation) and gives a UI something to show an "ARMING" state
|
|
600
|
+
* for. A rapid re-toggle cancels the previous pending bump/revert.
|
|
601
|
+
*/
|
|
602
|
+
_setForced(flightId, force) {
|
|
603
|
+
const pending = this._forceTimers.get(flightId);
|
|
604
|
+
if (pending !== undefined) {
|
|
605
|
+
clearTimeout(pending);
|
|
606
|
+
this._forceTimers.delete(flightId);
|
|
607
|
+
}
|
|
608
|
+
const cam = this._cameras.get(flightId);
|
|
609
|
+
if (!cam)
|
|
610
|
+
return;
|
|
611
|
+
if (force) {
|
|
612
|
+
if (!this._preForceSize.has(flightId)) {
|
|
613
|
+
this._preForceSize.set(flightId, { width: cam.operatorWidth, height: cam.operatorHeight });
|
|
614
|
+
}
|
|
615
|
+
const max = this._maxRenderSizes.get(flightId);
|
|
616
|
+
const timer = setTimeout(() => {
|
|
617
|
+
this._forceTimers.delete(flightId);
|
|
618
|
+
this._applyRenderSize(flightId, max?.maxWidth, max?.maxHeight);
|
|
619
|
+
}, this._forceBumpDelayMs);
|
|
620
|
+
this._forceTimers.set(flightId, timer);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
const prior = this._preForceSize.get(flightId);
|
|
624
|
+
this._preForceSize.delete(flightId);
|
|
625
|
+
if (!prior)
|
|
626
|
+
return;
|
|
627
|
+
const timer = setTimeout(() => {
|
|
628
|
+
this._forceTimers.delete(flightId);
|
|
629
|
+
this._applyRenderSize(flightId, prior.width, prior.height);
|
|
630
|
+
}, this._forceBumpDelayMs);
|
|
631
|
+
this._forceTimers.set(flightId, timer);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
|
|
635
|
+
_applyRenderSize(flightId, width, height) {
|
|
636
|
+
const cam = this._cameras.get(flightId);
|
|
637
|
+
if (!cam || width === undefined || height === undefined)
|
|
638
|
+
return;
|
|
639
|
+
const updated = {
|
|
640
|
+
...cam,
|
|
641
|
+
renderWidth: width,
|
|
642
|
+
renderHeight: height,
|
|
643
|
+
operatorWidth: width,
|
|
644
|
+
operatorHeight: height,
|
|
645
|
+
};
|
|
646
|
+
this._cameras.set(flightId, updated);
|
|
647
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
648
|
+
}
|
|
501
649
|
}
|
|
502
650
|
/**
|
|
503
651
|
* Install jsdom shims needed by kerbcast component tests.
|
|
@@ -544,6 +692,14 @@ export function installDomStubs() {
|
|
|
544
692
|
// with tracks. Provide a stub that satisfies the basic interface used by
|
|
545
693
|
// tests (getTracks, addTrack, id).
|
|
546
694
|
installStubMediaStream();
|
|
695
|
+
// MediaRecorder: jsdom ships none, so the recording controller can't be
|
|
696
|
+
// exercised headlessly without it. The stub emits one canned Blob on stop,
|
|
697
|
+
// closing the start -> stop -> Blob path. Its isTypeSupported is controllable
|
|
698
|
+
// (StubMediaRecorder.supportedTypes) so tests can drive mime negotiation and
|
|
699
|
+
// its fallback.
|
|
700
|
+
if (typeof globalThis.MediaRecorder === "undefined") {
|
|
701
|
+
globalThis.MediaRecorder = StubMediaRecorder;
|
|
702
|
+
}
|
|
547
703
|
// play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
|
|
548
704
|
// The noise pipeline awaits video.play() on its internal video element.
|
|
549
705
|
if (typeof HTMLMediaElement !== "undefined") {
|
|
@@ -567,9 +723,18 @@ export function installDomStubs() {
|
|
|
567
723
|
}
|
|
568
724
|
/** Minimal MediaStream stand-in for jsdom environments. */
|
|
569
725
|
class StubMediaStream {
|
|
570
|
-
|
|
726
|
+
/**
|
|
727
|
+
* Mirrors the real `MediaStream(tracks?)` constructor: the real SDK builds
|
|
728
|
+
* its per-camera stream as `new MediaStream([track])` on track arrival, so
|
|
729
|
+
* without this the tracks array is silently dropped and the stream always
|
|
730
|
+
* reports zero video tracks: the client and anything downstream (e.g. a
|
|
731
|
+
* real `RecordingController.startRecording`, which requires at least one
|
|
732
|
+
* live video track) would never see the delivered track.
|
|
733
|
+
*/
|
|
734
|
+
constructor(tracks = []) {
|
|
571
735
|
this.id = Math.random().toString(36).slice(2);
|
|
572
736
|
this._tracks = [];
|
|
737
|
+
this._tracks.push(...tracks);
|
|
573
738
|
}
|
|
574
739
|
getTracks() {
|
|
575
740
|
return [...this._tracks];
|
|
@@ -608,3 +773,68 @@ function installStubMediaStream() {
|
|
|
608
773
|
}
|
|
609
774
|
globalThis.MediaStream = StubMediaStream;
|
|
610
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
|
|
778
|
+
*
|
|
779
|
+
* jsdom implements no MediaRecorder, so the recording controller can't run
|
|
780
|
+
* headlessly without this. On `stop()` it synchronously flushes one canned
|
|
781
|
+
* Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
|
|
782
|
+
* `stop`, closing the start -> stop -> Blob path the controller drives.
|
|
783
|
+
*
|
|
784
|
+
* `isTypeSupported` is controllable so tests exercise mime negotiation:
|
|
785
|
+
* - `supportedTypes = null` (default): support everything, so negotiation
|
|
786
|
+
* picks the mp4 preference.
|
|
787
|
+
* - `supportedTypes = ["video/webm"]`: force the webm fallback.
|
|
788
|
+
* Reset `supportedTypes` back to null between tests that change it.
|
|
789
|
+
*/
|
|
790
|
+
export class StubMediaRecorder {
|
|
791
|
+
static isTypeSupported(type) {
|
|
792
|
+
return StubMediaRecorder.supportedTypes === null
|
|
793
|
+
? true
|
|
794
|
+
: StubMediaRecorder.supportedTypes.includes(type);
|
|
795
|
+
}
|
|
796
|
+
constructor(stream, options) {
|
|
797
|
+
this.state = "inactive";
|
|
798
|
+
this._listeners = new Map();
|
|
799
|
+
this.stream = stream;
|
|
800
|
+
this.mimeType = options?.mimeType ?? "";
|
|
801
|
+
}
|
|
802
|
+
addEventListener(type, cb) {
|
|
803
|
+
let set = this._listeners.get(type);
|
|
804
|
+
if (!set) {
|
|
805
|
+
set = new Set();
|
|
806
|
+
this._listeners.set(type, set);
|
|
807
|
+
}
|
|
808
|
+
set.add(cb);
|
|
809
|
+
}
|
|
810
|
+
removeEventListener(type, cb) {
|
|
811
|
+
this._listeners.get(type)?.delete(cb);
|
|
812
|
+
}
|
|
813
|
+
_emit(type, e) {
|
|
814
|
+
this._listeners.get(type)?.forEach((cb) => cb(e));
|
|
815
|
+
}
|
|
816
|
+
start(_timeslice) {
|
|
817
|
+
this.state = "recording";
|
|
818
|
+
}
|
|
819
|
+
/** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
|
|
820
|
+
stop() {
|
|
821
|
+
if (this.state === "inactive")
|
|
822
|
+
return;
|
|
823
|
+
this.state = "inactive";
|
|
824
|
+
const bytes = new Uint8Array(2048);
|
|
825
|
+
for (let i = 0; i < bytes.length; i++)
|
|
826
|
+
bytes[i] = i & 0xff;
|
|
827
|
+
const data = new Blob([bytes], { type: this.mimeType || "video/webm" });
|
|
828
|
+
this._emit("dataavailable", { data });
|
|
829
|
+
this._emit("stop", {});
|
|
830
|
+
}
|
|
831
|
+
/** Emit a partial data chunk without stopping (matches the real API). */
|
|
832
|
+
requestData() {
|
|
833
|
+
if (this.state !== "recording")
|
|
834
|
+
return;
|
|
835
|
+
const data = new Blob([new Uint8Array(512)], { type: this.mimeType || "video/webm" });
|
|
836
|
+
this._emit("dataavailable", { data });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/** Types isTypeSupported reports available; null = everything. */
|
|
840
|
+
StubMediaRecorder.supportedTypes = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -54,5 +54,8 @@
|
|
|
54
54
|
"jsdom": "^29.0.2",
|
|
55
55
|
"typescript": "^5.4.0",
|
|
56
56
|
"vitest": "^4.1.4"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"mediabunny": "^1.51.0"
|
|
57
60
|
}
|
|
58
61
|
}
|
|
@@ -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,
|
|
@@ -213,6 +281,18 @@ export interface SetDegradePayload {
|
|
|
213
281
|
level: number;
|
|
214
282
|
}
|
|
215
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Forces a camera to render at the operator ceiling regardless of
|
|
286
|
+
* display-size demand. Per-viewer, OR-aggregated by the sidecar: a feed
|
|
287
|
+
* stays forced while any connected viewer forces it, and releases when
|
|
288
|
+
* the last one clears it (or disconnects). Still yields to the adaptive
|
|
289
|
+
* framerate shed; overrides only the demand term.
|
|
290
|
+
*/
|
|
291
|
+
export interface SetForceFullResolutionPayload {
|
|
292
|
+
flightId: number;
|
|
293
|
+
force: boolean;
|
|
294
|
+
}
|
|
295
|
+
|
|
216
296
|
export interface SetFovPayload {
|
|
217
297
|
flightId: number;
|
|
218
298
|
fov: number;
|
|
@@ -272,6 +352,22 @@ export interface SetThrottleMainScreenPayload {
|
|
|
272
352
|
enabled: boolean;
|
|
273
353
|
}
|
|
274
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
|
|
357
|
+
* `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
|
|
358
|
+
* chosen mode per camera and publishes it in `CameraState.track_mode`, so
|
|
359
|
+
* every browser reflects the same tracking state (never optimistic-local).
|
|
360
|
+
*
|
|
361
|
+
* Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
|
|
362
|
+
* same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
|
|
363
|
+
* camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
|
|
364
|
+
* zoom over any manual FoV; `mode: none` restores manual zoom untouched.
|
|
365
|
+
*/
|
|
366
|
+
export interface SetTrackTargetPayload {
|
|
367
|
+
flightId: number;
|
|
368
|
+
mode: TrackMode;
|
|
369
|
+
}
|
|
370
|
+
|
|
275
371
|
export interface SetZoomRatePayload {
|
|
276
372
|
flightId: number;
|
|
277
373
|
/**
|
|
@@ -345,10 +441,38 @@ export type ClientMessage =
|
|
|
345
441
|
*/
|
|
346
442
|
| { type: "set-layers", content: SetLayersPayload }
|
|
347
443
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
444
|
+
* Cap the auto-resolution for one camera. As of Stage 6 this is a
|
|
445
|
+
* CEILING on the demand-driven size (effective = min(auto max-across-
|
|
446
|
+
* consumers, this cap)), NOT an authoritative set: it cannot force a
|
|
447
|
+
* camera larger than current consumers report, only smaller. Even
|
|
448
|
+
* pixels only (H.264 chroma); server caps at the ring's allocated max.
|
|
350
449
|
*/
|
|
351
450
|
| { type: "set-render-size", content: SetRenderSizePayload }
|
|
451
|
+
/**
|
|
452
|
+
* Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
|
|
453
|
+
* holds the chosen `mode` per camera and publishes it in
|
|
454
|
+
* `CameraState.track_mode`, so tracking state is server-authoritative and
|
|
455
|
+
* consistent across browsers. A browser track OVERRIDES a kOS aim on the
|
|
456
|
+
* same camera; send `mode: none` to hand aiming back to kOS.
|
|
457
|
+
*/
|
|
458
|
+
| { type: "set-track-target", content: SetTrackTargetPayload }
|
|
459
|
+
/**
|
|
460
|
+
* A consumer reporting its OWN current display size in px; the sidecar
|
|
461
|
+
* aggregates MAX-across-consumers to drive auto-resolution
|
|
462
|
+
* (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
|
|
463
|
+
* command: this is a per-consumer input, not a shared override — a
|
|
464
|
+
* departing consumer's size is cleared so the max relaxes.
|
|
465
|
+
*/
|
|
466
|
+
| { type: "report-display-size", content: ReportDisplaySizePayload }
|
|
467
|
+
/**
|
|
468
|
+
* Force a camera to render at the operator ceiling regardless of
|
|
469
|
+
* display-size demand. Per-viewer, OR-aggregated by the sidecar: a
|
|
470
|
+
* feed stays forced while any connected viewer forces it, and
|
|
471
|
+
* releases when the last one clears it (or disconnects). Still
|
|
472
|
+
* yields to the adaptive framerate shed; overrides only the demand
|
|
473
|
+
* term, never the perf-protection ceiling.
|
|
474
|
+
*/
|
|
475
|
+
| { type: "set-force-full-resolution", content: SetForceFullResolutionPayload }
|
|
352
476
|
/**
|
|
353
477
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
354
478
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|