@ksp-gonogo/kerbcast 1.7.0 → 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 +39 -0
- package/dist/__generated__/types.d.ts +23 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +43 -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 +88 -1
- package/dist/testing/index.js +200 -3
- package/package.json +4 -1
- package/src/__generated__/types.ts +21 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +74 -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 +239 -1
- package/src/testing.test.ts +147 -1
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
|
|
2
2
|
import { CameraKind, CameraLifecycle, CrewLocation, Layer, QualityPreset, TrackMode } from "../__generated__/types";
|
|
3
|
-
import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
|
|
3
|
+
import type { DiscoveredCamera, InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
|
|
4
4
|
export interface MockCameraInit {
|
|
5
5
|
flightId: number;
|
|
6
6
|
lifecycle?: CameraLifecycle;
|
|
@@ -39,6 +39,27 @@ export interface MockCameraInit {
|
|
|
39
39
|
qualityLimitedBy?: string;
|
|
40
40
|
/** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
|
|
41
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;
|
|
42
63
|
}
|
|
43
64
|
/**
|
|
44
65
|
* In-process protocol-level fake for the kerbcast sidecar.
|
|
@@ -66,6 +87,14 @@ export declare class MockSidecar {
|
|
|
66
87
|
private readonly _cameras;
|
|
67
88
|
private readonly _commands;
|
|
68
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);
|
|
69
98
|
private _openHandler;
|
|
70
99
|
private _clientMsgHandler;
|
|
71
100
|
private _stateHandler;
|
|
@@ -89,6 +118,14 @@ export declare class MockSidecar {
|
|
|
89
118
|
private readonly _deliveredTracks;
|
|
90
119
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
91
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[];
|
|
92
129
|
/**
|
|
93
130
|
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
94
131
|
* second argument to `KerbcastClient`.
|
|
@@ -209,6 +246,18 @@ export declare class MockSidecar {
|
|
|
209
246
|
private _buildStatsReport;
|
|
210
247
|
private _sendToClient;
|
|
211
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;
|
|
212
261
|
}
|
|
213
262
|
/**
|
|
214
263
|
* Install jsdom shims needed by kerbcast component tests.
|
|
@@ -234,3 +283,41 @@ export declare class MockSidecar {
|
|
|
234
283
|
* detection reads prefers-color-scheme through it.
|
|
235
284
|
*/
|
|
236
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
|
@@ -71,10 +71,16 @@ function scaleDim(operatorDim, scale) {
|
|
|
71
71
|
* ```
|
|
72
72
|
*/
|
|
73
73
|
export class MockSidecar {
|
|
74
|
-
constructor() {
|
|
74
|
+
constructor(opts = {}) {
|
|
75
75
|
this._cameras = new Map();
|
|
76
76
|
this._commands = [];
|
|
77
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();
|
|
78
84
|
this._trackIdx = 0;
|
|
79
85
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
80
86
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -91,10 +97,50 @@ export class MockSidecar {
|
|
|
91
97
|
* getStats can synthesize a trackIdentifier matching what the client saw.
|
|
92
98
|
*/
|
|
93
99
|
this._deliveredTracks = new Map();
|
|
100
|
+
this._forceBumpDelayMs = opts.forceBumpDelayMs ?? 600;
|
|
94
101
|
}
|
|
95
102
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
96
103
|
addCamera(init) {
|
|
97
|
-
|
|
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
|
+
});
|
|
98
144
|
}
|
|
99
145
|
/**
|
|
100
146
|
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
@@ -185,9 +231,18 @@ export class MockSidecar {
|
|
|
185
231
|
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
186
232
|
*/
|
|
187
233
|
setCameras(inits) {
|
|
234
|
+
for (const timer of this._forceTimers.values())
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
this._forceTimers.clear();
|
|
237
|
+
this._preForceSize.clear();
|
|
188
238
|
this._cameras.clear();
|
|
239
|
+
this._maxRenderSizes.clear();
|
|
189
240
|
for (const init of inits) {
|
|
190
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
|
+
});
|
|
191
246
|
}
|
|
192
247
|
this._sendToClient({
|
|
193
248
|
type: "camera-snapshot",
|
|
@@ -527,10 +582,70 @@ export class MockSidecar {
|
|
|
527
582
|
});
|
|
528
583
|
break;
|
|
529
584
|
}
|
|
585
|
+
case "set-force-full-resolution": {
|
|
586
|
+
this._setForced(msg.content.flightId, msg.content.force);
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
530
589
|
case "disconnect":
|
|
531
590
|
break;
|
|
532
591
|
}
|
|
533
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
|
+
}
|
|
534
649
|
}
|
|
535
650
|
/**
|
|
536
651
|
* Install jsdom shims needed by kerbcast component tests.
|
|
@@ -577,6 +692,14 @@ export function installDomStubs() {
|
|
|
577
692
|
// with tracks. Provide a stub that satisfies the basic interface used by
|
|
578
693
|
// tests (getTracks, addTrack, id).
|
|
579
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
|
+
}
|
|
580
703
|
// play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
|
|
581
704
|
// The noise pipeline awaits video.play() on its internal video element.
|
|
582
705
|
if (typeof HTMLMediaElement !== "undefined") {
|
|
@@ -600,9 +723,18 @@ export function installDomStubs() {
|
|
|
600
723
|
}
|
|
601
724
|
/** Minimal MediaStream stand-in for jsdom environments. */
|
|
602
725
|
class StubMediaStream {
|
|
603
|
-
|
|
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 = []) {
|
|
604
735
|
this.id = Math.random().toString(36).slice(2);
|
|
605
736
|
this._tracks = [];
|
|
737
|
+
this._tracks.push(...tracks);
|
|
606
738
|
}
|
|
607
739
|
getTracks() {
|
|
608
740
|
return [...this._tracks];
|
|
@@ -641,3 +773,68 @@ function installStubMediaStream() {
|
|
|
641
773
|
}
|
|
642
774
|
globalThis.MediaStream = StubMediaStream;
|
|
643
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
|
}
|
|
@@ -281,6 +281,18 @@ export interface SetDegradePayload {
|
|
|
281
281
|
level: number;
|
|
282
282
|
}
|
|
283
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
|
+
|
|
284
296
|
export interface SetFovPayload {
|
|
285
297
|
flightId: number;
|
|
286
298
|
fov: number;
|
|
@@ -452,6 +464,15 @@ export type ClientMessage =
|
|
|
452
464
|
* departing consumer's size is cleared so the max relaxes.
|
|
453
465
|
*/
|
|
454
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 }
|
|
455
476
|
/**
|
|
456
477
|
* Set the camera's field-of-view (degrees). Silently ignored for
|
|
457
478
|
* parts whose Hullcam module is the fixed base (`supportsZoom ==
|
package/src/client.test.ts
CHANGED
|
@@ -382,6 +382,30 @@ describe("KerbcastClient", () => {
|
|
|
382
382
|
]);
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
it("set-force-full-resolution routes onto the control channel via the handle", 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; // drop the hello
|
|
393
|
+
|
|
394
|
+
await client.camera(42).setForceFullResolution(true);
|
|
395
|
+
await client.camera(42).setForceFullResolution(false);
|
|
396
|
+
|
|
397
|
+
expect(captured.dc?.sent).toEqual([
|
|
398
|
+
JSON.stringify({
|
|
399
|
+
type: "set-force-full-resolution",
|
|
400
|
+
content: { flightId: 42, force: true },
|
|
401
|
+
}),
|
|
402
|
+
JSON.stringify({
|
|
403
|
+
type: "set-force-full-resolution",
|
|
404
|
+
content: { flightId: 42, force: false },
|
|
405
|
+
}),
|
|
406
|
+
]);
|
|
407
|
+
});
|
|
408
|
+
|
|
385
409
|
it("reportDisplaySize sends a report-display-size command", async () => {
|
|
386
410
|
const { transport, captured } = makeFakeTransport();
|
|
387
411
|
const client = new KerbcastClient({ host: "h", port: 1 }, transport);
|
package/src/client.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
} from "./__generated__/types";
|
|
12
12
|
import { CameraLifecycle, ErrorSource } from "./__generated__/types";
|
|
13
13
|
import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
|
|
14
|
+
import { RecordingController, type RecordingControllerOptions } from "./recording";
|
|
14
15
|
|
|
15
16
|
/** Intensity the static runs at when a camera has no live source (signal lost). */
|
|
16
17
|
const SOURCELESS_INTENSITY = 1.0;
|
|
@@ -47,6 +48,16 @@ export interface KerbcastCameraHandle {
|
|
|
47
48
|
readonly stalled: boolean;
|
|
48
49
|
/** Current {@link setShowStatic} setting. */
|
|
49
50
|
readonly showStatic: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
|
|
53
|
+
* / `maxHeight`), or `null` until known. Populated from a prior
|
|
54
|
+
* `client.discover()` call (there is no live-pushed field for it), so a
|
|
55
|
+
* consumer that never calls `discover()` sees `null` here indefinitely.
|
|
56
|
+
* Used by `RecordingController` to recognise a force-full-resolution feed
|
|
57
|
+
* that has actually reached its ceiling, as opposed to one merely
|
|
58
|
+
* unchanged since before the force was sent.
|
|
59
|
+
*/
|
|
60
|
+
readonly maxRenderSize: { width: number; height: number } | null;
|
|
50
61
|
|
|
51
62
|
setLayers(layers: Layer[]): Promise<void>;
|
|
52
63
|
setRenderSize(width: number, height: number): Promise<void>;
|
|
@@ -78,6 +89,15 @@ export interface KerbcastCameraHandle {
|
|
|
78
89
|
* machinery keeps overriding it downward while throttled.
|
|
79
90
|
*/
|
|
80
91
|
setQuality(preset: QualityPreset | null): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Force this feed to render at the operator's configured maximum
|
|
94
|
+
* regardless of display size, until released by calling this with
|
|
95
|
+
* `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
|
|
96
|
+
* stays forced while any viewer forces it, and releases when the last one
|
|
97
|
+
* clears it (or disconnects). Still yields to the adaptive framerate
|
|
98
|
+
* shed; it only overrides the display-size demand term.
|
|
99
|
+
*/
|
|
100
|
+
setForceFullResolution(force: boolean): Promise<void>;
|
|
81
101
|
requestKeyframe(): Promise<void>;
|
|
82
102
|
|
|
83
103
|
/**
|
|
@@ -150,6 +170,15 @@ export interface KerbcastClientConfig {
|
|
|
150
170
|
cameras: number[];
|
|
151
171
|
slots?: number;
|
|
152
172
|
}) => Promise<{ sdp: string; cameras: number[] }>;
|
|
173
|
+
/**
|
|
174
|
+
* Options for the lazily-built {@link RecordingController} (`client.recording`).
|
|
175
|
+
* The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
|
|
176
|
+
* default bare `import("mediabunny")` resolving (the sidecar's embedded web
|
|
177
|
+
* page serves its own copy of the package locally, offline/LAN, no CDN)
|
|
178
|
+
* supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
|
|
179
|
+
* this and get the default.
|
|
180
|
+
*/
|
|
181
|
+
recording?: RecordingControllerOptions;
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
/** WebRTC connection state surface. */
|
|
@@ -509,6 +538,10 @@ class CameraHandle
|
|
|
509
538
|
return this._showStatic;
|
|
510
539
|
}
|
|
511
540
|
|
|
541
|
+
get maxRenderSize(): { width: number; height: number } | null {
|
|
542
|
+
return this.client._maxRenderSize(this.flightId);
|
|
543
|
+
}
|
|
544
|
+
|
|
512
545
|
configure(options: { noise?: Partial<NoiseConfig> }): void {
|
|
513
546
|
this._noiseOverride = options.noise ?? null;
|
|
514
547
|
// Re-evaluate with the new noise setting: rebuild from whatever source
|
|
@@ -739,6 +772,13 @@ class CameraHandle
|
|
|
739
772
|
});
|
|
740
773
|
}
|
|
741
774
|
|
|
775
|
+
async setForceFullResolution(force: boolean): Promise<void> {
|
|
776
|
+
await this.client._send({
|
|
777
|
+
type: "set-force-full-resolution",
|
|
778
|
+
content: { flightId: this.flightId, force },
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
742
782
|
async requestKeyframe(): Promise<void> {
|
|
743
783
|
await this.client._send({
|
|
744
784
|
type: "request-keyframe",
|
|
@@ -816,6 +856,15 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
816
856
|
private _warpRate = 1;
|
|
817
857
|
/** Whether KSP is in a flight scene. `undefined` until the first signal. */
|
|
818
858
|
private _inFlight: boolean | undefined = undefined;
|
|
859
|
+
/** Client-side recording controller, lazily built on first `recording` access. */
|
|
860
|
+
private _recording: RecordingController | null = null;
|
|
861
|
+
/**
|
|
862
|
+
* Physical/ceiling max render size per camera, cached from the last
|
|
863
|
+
* `discover()` call. Empty until `discover()` has been called at least
|
|
864
|
+
* once; a camera absent from the last discovery response is simply
|
|
865
|
+
* missing here (its handle's `maxRenderSize` reads `null`).
|
|
866
|
+
*/
|
|
867
|
+
private readonly maxRenderSizeByFlight = new Map<number, { width: number; height: number }>();
|
|
819
868
|
|
|
820
869
|
constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport) {
|
|
821
870
|
super();
|
|
@@ -868,6 +917,17 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
868
917
|
};
|
|
869
918
|
}
|
|
870
919
|
|
|
920
|
+
/**
|
|
921
|
+
* Client-side recording controller. Records a camera's already-received
|
|
922
|
+
* track with the browser's `MediaRecorder`, annotated with the mission-time
|
|
923
|
+
* (UT) clock; purely local, no sidecar involvement. Lazily created on first
|
|
924
|
+
* access and stable thereafter. See {@link RecordingController}.
|
|
925
|
+
*/
|
|
926
|
+
get recording(): RecordingController {
|
|
927
|
+
if (!this._recording) this._recording = new RecordingController(this, this.cfg.recording);
|
|
928
|
+
return this._recording;
|
|
929
|
+
}
|
|
930
|
+
|
|
871
931
|
/**
|
|
872
932
|
* Whether KSP is currently in a flight scene, from the sidecar's
|
|
873
933
|
* `scene-state-changed`. `undefined` until the first signal (so a
|
|
@@ -899,9 +959,23 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
|
|
|
899
959
|
const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
|
|
900
960
|
if (!res.ok) throw new Error(`/cameras returned ${res.status}`);
|
|
901
961
|
const body = (await res.json()) as { cameras: DiscoveredCamera[] };
|
|
962
|
+
for (const cam of body.cameras) {
|
|
963
|
+
this.maxRenderSizeByFlight.set(cam.flightId, {
|
|
964
|
+
width: cam.maxWidth,
|
|
965
|
+
height: cam.maxHeight,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
902
968
|
return body.cameras;
|
|
903
969
|
}
|
|
904
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
|
|
973
|
+
* `discover()` has never been called or never reported this camera.
|
|
974
|
+
*/
|
|
975
|
+
_maxRenderSize(flightId: number): { width: number; height: number } | null {
|
|
976
|
+
return this.maxRenderSizeByFlight.get(flightId) ?? null;
|
|
977
|
+
}
|
|
978
|
+
|
|
905
979
|
/**
|
|
906
980
|
* Open the WebRTC peer and subscribe to cameras. Idempotent: calling
|
|
907
981
|
* `connect` while already connected disconnects first.
|
package/src/index.ts
CHANGED
|
@@ -27,3 +27,25 @@ export {
|
|
|
27
27
|
type PanZoomBounds,
|
|
28
28
|
type PanZoomControllerOptions,
|
|
29
29
|
} from "./panZoom";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
RecordingController,
|
|
33
|
+
createMediabunnyTrimmerLoader,
|
|
34
|
+
negotiateMimeType,
|
|
35
|
+
commonUtWindow,
|
|
36
|
+
utToRecordingTimeMs,
|
|
37
|
+
DEFAULT_COVERAGE_TIMEOUT_MS,
|
|
38
|
+
ARM_TIMEOUT_MS,
|
|
39
|
+
type ActiveRecordingInfo,
|
|
40
|
+
type RecordingClient,
|
|
41
|
+
type RecordingControllerOptions,
|
|
42
|
+
type RecordingHandle,
|
|
43
|
+
type RecordingsSnapshot,
|
|
44
|
+
type GroupedRecordingHandle,
|
|
45
|
+
type GroupedRecordingStart,
|
|
46
|
+
type GroupTrimmer,
|
|
47
|
+
type TrimmerLoader,
|
|
48
|
+
type UtSample,
|
|
49
|
+
type StartRecordingOptions,
|
|
50
|
+
type StopGroupedRecordingOptions,
|
|
51
|
+
} from "./recording";
|