@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/src/testing/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
DiscoveredCamera,
|
|
4
5
|
InboundVideoStats,
|
|
5
6
|
KerbcastConnectionState,
|
|
6
7
|
KerbcastDataChannel,
|
|
@@ -11,6 +12,13 @@ import type {
|
|
|
11
12
|
export interface MockCameraInit {
|
|
12
13
|
flightId: number;
|
|
13
14
|
lifecycle?: CameraLifecycle;
|
|
15
|
+
/** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
|
|
16
|
+
* part-cam callers are unchanged. */
|
|
17
|
+
kind?: CameraKind;
|
|
18
|
+
/** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
|
|
19
|
+
crewLocation?: CrewLocation;
|
|
20
|
+
/** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
|
|
21
|
+
kerbalPersistentId?: number;
|
|
14
22
|
partName?: string;
|
|
15
23
|
partTitle?: string;
|
|
16
24
|
cameraName?: string;
|
|
@@ -37,12 +45,27 @@ export interface MockCameraInit {
|
|
|
37
45
|
degradeLevel?: number;
|
|
38
46
|
viewerQuality?: QualityPreset;
|
|
39
47
|
qualityLimitedBy?: string;
|
|
48
|
+
/** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
|
|
49
|
+
trackMode?: TrackMode;
|
|
50
|
+
/**
|
|
51
|
+
* Physical/ceiling render size, served by the mock's `/cameras` discovery
|
|
52
|
+
* intercept (`DiscoveredCamera.maxWidth`/`maxHeight`). Defaults to
|
|
53
|
+
* `operatorWidth`/`operatorHeight` (i.e. already at ceiling) so a camera
|
|
54
|
+
* that never sets these behaves as it always has. Give a camera a ceiling
|
|
55
|
+
* above its initial `operatorWidth`/`operatorHeight` to demonstrate a
|
|
56
|
+
* visible bump when `set-force-full-resolution` lands.
|
|
57
|
+
*/
|
|
58
|
+
maxWidth?: number;
|
|
59
|
+
maxHeight?: number;
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
function buildCamera(init: MockCameraInit): CameraState {
|
|
43
63
|
return {
|
|
44
64
|
flightId: init.flightId,
|
|
45
65
|
lifecycle: init.lifecycle ?? CameraLifecycle.Active,
|
|
66
|
+
kind: init.kind ?? CameraKind.Part,
|
|
67
|
+
crewLocation: init.crewLocation,
|
|
68
|
+
kerbalPersistentId: init.kerbalPersistentId,
|
|
46
69
|
partName: init.partName ?? `part-${init.flightId}`,
|
|
47
70
|
partTitle: init.partTitle ?? `Part ${init.flightId}`,
|
|
48
71
|
cameraName: init.cameraName ?? `camera-${init.flightId}`,
|
|
@@ -69,6 +92,7 @@ function buildCamera(init: MockCameraInit): CameraState {
|
|
|
69
92
|
degradeLevel: init.degradeLevel ?? 0,
|
|
70
93
|
viewerQuality: init.viewerQuality,
|
|
71
94
|
qualityLimitedBy: init.qualityLimitedBy,
|
|
95
|
+
trackMode: init.trackMode ?? TrackMode.None,
|
|
72
96
|
};
|
|
73
97
|
}
|
|
74
98
|
|
|
@@ -87,6 +111,18 @@ function scaleDim(operatorDim: number, scale: number): number {
|
|
|
87
111
|
return v < 2 ? 2 : v;
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
/** Options for {@link MockSidecar}'s constructor. */
|
|
115
|
+
export interface MockSidecarOptions {
|
|
116
|
+
/**
|
|
117
|
+
* Delay (ms) before a forced camera's simulated resolution bump lands,
|
|
118
|
+
* once `set-force-full-resolution` (`force: true`) is received, and
|
|
119
|
+
* before the render size reverts on `force: false`. Long enough that a
|
|
120
|
+
* screenshot can catch the resulting "ARMING" state; short enough that
|
|
121
|
+
* automated tests/waits don't feel slow. Defaults to 600ms.
|
|
122
|
+
*/
|
|
123
|
+
forceBumpDelayMs?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
/**
|
|
91
127
|
* In-process protocol-level fake for the kerbcast sidecar.
|
|
92
128
|
*
|
|
@@ -113,6 +149,17 @@ export class MockSidecar {
|
|
|
113
149
|
private readonly _cameras = new Map<number, CameraState>();
|
|
114
150
|
private readonly _commands: ClientMessage[] = [];
|
|
115
151
|
private _throttleMainScreen = false;
|
|
152
|
+
private readonly _forceBumpDelayMs: number;
|
|
153
|
+
/** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
|
|
154
|
+
private readonly _maxRenderSizes = new Map<number, { maxWidth: number; maxHeight: number }>();
|
|
155
|
+
/** Per-flight render size captured just before a force bump, restored on release. */
|
|
156
|
+
private readonly _preForceSize = new Map<number, { width: number; height: number }>();
|
|
157
|
+
/** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
|
|
158
|
+
private readonly _forceTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
|
159
|
+
|
|
160
|
+
constructor(opts: MockSidecarOptions = {}) {
|
|
161
|
+
this._forceBumpDelayMs = opts.forceBumpDelayMs ?? 600;
|
|
162
|
+
}
|
|
116
163
|
|
|
117
164
|
private _openHandler: (() => void) | undefined;
|
|
118
165
|
private _clientMsgHandler: ((raw: string) => void) | undefined;
|
|
@@ -120,6 +167,9 @@ export class MockSidecar {
|
|
|
120
167
|
private _onTrackHandler:
|
|
121
168
|
| ((track: MediaStreamTrack, idx: number, mid: string) => void)
|
|
122
169
|
| undefined;
|
|
170
|
+
private _subscribeHandler:
|
|
171
|
+
| ((flightId: number, mid: string) => void)
|
|
172
|
+
| undefined;
|
|
123
173
|
private _trackIdx = 0;
|
|
124
174
|
/** Slot mids available for the dynamic-subscription model. Override with
|
|
125
175
|
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
@@ -139,7 +189,47 @@ export class MockSidecar {
|
|
|
139
189
|
|
|
140
190
|
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
141
191
|
addCamera(init: MockCameraInit): void {
|
|
142
|
-
|
|
192
|
+
const cam = buildCamera(init);
|
|
193
|
+
this._cameras.set(init.flightId, cam);
|
|
194
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
195
|
+
maxWidth: init.maxWidth ?? cam.operatorWidth,
|
|
196
|
+
maxHeight: init.maxHeight ?? cam.operatorHeight,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The `DiscoveredCamera[]` the mock's `GET /cameras` intercept serves,
|
|
202
|
+
* mirroring the real sidecar's discovery endpoint. `maxWidth`/`maxHeight`
|
|
203
|
+
* come from each camera's `maxWidth`/`maxHeight` init (or its initial
|
|
204
|
+
* `operatorWidth`/`operatorHeight` when omitted). Used by
|
|
205
|
+
* `client.discover()` to populate `KerbcastCameraHandle.maxRenderSize`.
|
|
206
|
+
*/
|
|
207
|
+
discoveredCameras(): DiscoveredCamera[] {
|
|
208
|
+
return Array.from(this._cameras.values()).map((cam) => {
|
|
209
|
+
const max = this._maxRenderSizes.get(cam.flightId);
|
|
210
|
+
return {
|
|
211
|
+
flightId: cam.flightId,
|
|
212
|
+
lifecycle: cam.lifecycle,
|
|
213
|
+
partName: cam.partName,
|
|
214
|
+
partTitle: cam.partTitle,
|
|
215
|
+
cameraName: cam.cameraName,
|
|
216
|
+
vesselName: cam.vesselName,
|
|
217
|
+
maxWidth: max?.maxWidth ?? cam.operatorWidth,
|
|
218
|
+
maxHeight: max?.maxHeight ?? cam.operatorHeight,
|
|
219
|
+
supportsZoom: cam.supportsZoom,
|
|
220
|
+
fov: cam.fov,
|
|
221
|
+
fovMin: cam.fovMin,
|
|
222
|
+
fovMax: cam.fovMax,
|
|
223
|
+
supportsPan: cam.supportsPan,
|
|
224
|
+
panYawMin: cam.panYawMin,
|
|
225
|
+
panYawMax: cam.panYawMax,
|
|
226
|
+
panPitchMin: cam.panPitchMin,
|
|
227
|
+
panPitchMax: cam.panPitchMax,
|
|
228
|
+
encoderBitrateBps: cam.encoderBitrateBps,
|
|
229
|
+
targetBitrateBps: cam.targetBitrateBps,
|
|
230
|
+
degradeLevel: cam.degradeLevel,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
143
233
|
}
|
|
144
234
|
|
|
145
235
|
/**
|
|
@@ -234,9 +324,17 @@ export class MockSidecar {
|
|
|
234
324
|
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
235
325
|
*/
|
|
236
326
|
setCameras(inits: MockCameraInit[]): void {
|
|
327
|
+
for (const timer of this._forceTimers.values()) clearTimeout(timer);
|
|
328
|
+
this._forceTimers.clear();
|
|
329
|
+
this._preForceSize.clear();
|
|
237
330
|
this._cameras.clear();
|
|
331
|
+
this._maxRenderSizes.clear();
|
|
238
332
|
for (const init of inits) {
|
|
239
333
|
this._cameras.set(init.flightId, buildCamera(init));
|
|
334
|
+
this._maxRenderSizes.set(init.flightId, {
|
|
335
|
+
maxWidth: init.maxWidth ?? init.operatorWidth ?? 1280,
|
|
336
|
+
maxHeight: init.maxHeight ?? init.operatorHeight ?? 720,
|
|
337
|
+
});
|
|
240
338
|
}
|
|
241
339
|
this._sendToClient({
|
|
242
340
|
type: "camera-snapshot",
|
|
@@ -295,6 +393,16 @@ export class MockSidecar {
|
|
|
295
393
|
this._onTrackHandler?.(track, this._trackIdx++, mid);
|
|
296
394
|
}
|
|
297
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Register a handler fired each time a `subscribe` binds a camera to a slot,
|
|
398
|
+
* with `(flightId, mid)`. A browser harness uses it to deliver that camera's
|
|
399
|
+
* track to the mid it was actually bound to — so tracks follow the real
|
|
400
|
+
* subscription order, not the registration/array order. Set before connecting.
|
|
401
|
+
*/
|
|
402
|
+
onSubscribe(handler: (flightId: number, mid: string) => void): void {
|
|
403
|
+
this._subscribeHandler = handler;
|
|
404
|
+
}
|
|
405
|
+
|
|
298
406
|
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
299
407
|
slotMidFor(flightId: number): string | undefined {
|
|
300
408
|
for (const [mid, fid] of this._slotBindings) {
|
|
@@ -520,6 +628,18 @@ export class MockSidecar {
|
|
|
520
628
|
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
521
629
|
break;
|
|
522
630
|
}
|
|
631
|
+
case "set-track-target": {
|
|
632
|
+
// Server-authoritative: hold the chosen mode and broadcast it back as
|
|
633
|
+
// camera-state-changed, mirroring the sidecar so every browser reflects
|
|
634
|
+
// the same trackMode (never optimistic-local).
|
|
635
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
636
|
+
if (cam) {
|
|
637
|
+
const updated: CameraState = { ...cam, trackMode: msg.content.mode };
|
|
638
|
+
this._cameras.set(msg.content.flightId, updated);
|
|
639
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
640
|
+
}
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
523
643
|
case "subscribe": {
|
|
524
644
|
const flightId = msg.content.flightId;
|
|
525
645
|
const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
|
|
@@ -534,6 +654,9 @@ export class MockSidecar {
|
|
|
534
654
|
type: "slot-map",
|
|
535
655
|
content: { mid: freeMid, flightId },
|
|
536
656
|
});
|
|
657
|
+
// Let a harness deliver this camera's track to the slot it was just
|
|
658
|
+
// bound to (so tracks follow the actual subscription, not array order).
|
|
659
|
+
this._subscribeHandler?.(flightId, freeMid);
|
|
537
660
|
}
|
|
538
661
|
break;
|
|
539
662
|
}
|
|
@@ -559,6 +682,11 @@ export class MockSidecar {
|
|
|
559
682
|
// the mock has no frame clock, so it doesn't model their *effect* on
|
|
560
683
|
// panYaw/fov. They're still recorded in `_commands`, so consumer tests
|
|
561
684
|
// can assert the command was sent via `lastCommand("set-pan-rate")`.
|
|
685
|
+
// Advisory per-consumer display-size input. The real sidecar aggregates
|
|
686
|
+
// it MAX-across-consumers to drive auto-resolution; the mock has no
|
|
687
|
+
// aggregator, so it just records the command (via `_commands`) and does
|
|
688
|
+
// NOT mutate camera render dims (unlike the operator `set-render-size`).
|
|
689
|
+
case "report-display-size":
|
|
562
690
|
case "set-pan-rate":
|
|
563
691
|
case "set-zoom-rate":
|
|
564
692
|
case "hello":
|
|
@@ -574,10 +702,70 @@ export class MockSidecar {
|
|
|
574
702
|
});
|
|
575
703
|
break;
|
|
576
704
|
}
|
|
705
|
+
case "set-force-full-resolution": {
|
|
706
|
+
this._setForced(msg.content.flightId, msg.content.force);
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
577
709
|
case "disconnect":
|
|
578
710
|
break;
|
|
579
711
|
}
|
|
580
712
|
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Simulate the sidecar's `effective_render_size` force branch: after
|
|
716
|
+
* {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
|
|
717
|
+
* ceiling (`force: true`) or restore whatever it was before the force
|
|
718
|
+
* (`force: false`), pushing a `camera-state-changed` either way. The delay
|
|
719
|
+
* models the real round trip (control-file write, plugin capture pickup,
|
|
720
|
+
* WebRTC renegotiation) and gives a UI something to show an "ARMING" state
|
|
721
|
+
* for. A rapid re-toggle cancels the previous pending bump/revert.
|
|
722
|
+
*/
|
|
723
|
+
private _setForced(flightId: number, force: boolean): void {
|
|
724
|
+
const pending = this._forceTimers.get(flightId);
|
|
725
|
+
if (pending !== undefined) {
|
|
726
|
+
clearTimeout(pending);
|
|
727
|
+
this._forceTimers.delete(flightId);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const cam = this._cameras.get(flightId);
|
|
731
|
+
if (!cam) return;
|
|
732
|
+
|
|
733
|
+
if (force) {
|
|
734
|
+
if (!this._preForceSize.has(flightId)) {
|
|
735
|
+
this._preForceSize.set(flightId, { width: cam.operatorWidth, height: cam.operatorHeight });
|
|
736
|
+
}
|
|
737
|
+
const max = this._maxRenderSizes.get(flightId);
|
|
738
|
+
const timer = setTimeout(() => {
|
|
739
|
+
this._forceTimers.delete(flightId);
|
|
740
|
+
this._applyRenderSize(flightId, max?.maxWidth, max?.maxHeight);
|
|
741
|
+
}, this._forceBumpDelayMs);
|
|
742
|
+
this._forceTimers.set(flightId, timer);
|
|
743
|
+
} else {
|
|
744
|
+
const prior = this._preForceSize.get(flightId);
|
|
745
|
+
this._preForceSize.delete(flightId);
|
|
746
|
+
if (!prior) return;
|
|
747
|
+
const timer = setTimeout(() => {
|
|
748
|
+
this._forceTimers.delete(flightId);
|
|
749
|
+
this._applyRenderSize(flightId, prior.width, prior.height);
|
|
750
|
+
}, this._forceBumpDelayMs);
|
|
751
|
+
this._forceTimers.set(flightId, timer);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
|
|
756
|
+
private _applyRenderSize(flightId: number, width: number | undefined, height: number | undefined): void {
|
|
757
|
+
const cam = this._cameras.get(flightId);
|
|
758
|
+
if (!cam || width === undefined || height === undefined) return;
|
|
759
|
+
const updated: CameraState = {
|
|
760
|
+
...cam,
|
|
761
|
+
renderWidth: width,
|
|
762
|
+
renderHeight: height,
|
|
763
|
+
operatorWidth: width,
|
|
764
|
+
operatorHeight: height,
|
|
765
|
+
};
|
|
766
|
+
this._cameras.set(flightId, updated);
|
|
767
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
768
|
+
}
|
|
581
769
|
}
|
|
582
770
|
|
|
583
771
|
/**
|
|
@@ -630,6 +818,15 @@ export function installDomStubs(): void {
|
|
|
630
818
|
// tests (getTracks, addTrack, id).
|
|
631
819
|
installStubMediaStream();
|
|
632
820
|
|
|
821
|
+
// MediaRecorder: jsdom ships none, so the recording controller can't be
|
|
822
|
+
// exercised headlessly without it. The stub emits one canned Blob on stop,
|
|
823
|
+
// closing the start -> stop -> Blob path. Its isTypeSupported is controllable
|
|
824
|
+
// (StubMediaRecorder.supportedTypes) so tests can drive mime negotiation and
|
|
825
|
+
// its fallback.
|
|
826
|
+
if (typeof (globalThis as { MediaRecorder?: unknown }).MediaRecorder === "undefined") {
|
|
827
|
+
(globalThis as { MediaRecorder?: unknown }).MediaRecorder = StubMediaRecorder;
|
|
828
|
+
}
|
|
829
|
+
|
|
633
830
|
// play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
|
|
634
831
|
// The noise pipeline awaits video.play() on its internal video element.
|
|
635
832
|
if (typeof HTMLMediaElement !== "undefined") {
|
|
@@ -658,6 +855,18 @@ class StubMediaStream {
|
|
|
658
855
|
readonly id: string = Math.random().toString(36).slice(2);
|
|
659
856
|
private readonly _tracks: MediaStreamTrack[] = [];
|
|
660
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Mirrors the real `MediaStream(tracks?)` constructor: the real SDK builds
|
|
860
|
+
* its per-camera stream as `new MediaStream([track])` on track arrival, so
|
|
861
|
+
* without this the tracks array is silently dropped and the stream always
|
|
862
|
+
* reports zero video tracks: the client and anything downstream (e.g. a
|
|
863
|
+
* real `RecordingController.startRecording`, which requires at least one
|
|
864
|
+
* live video track) would never see the delivered track.
|
|
865
|
+
*/
|
|
866
|
+
constructor(tracks: MediaStreamTrack[] = []) {
|
|
867
|
+
this._tracks.push(...tracks);
|
|
868
|
+
}
|
|
869
|
+
|
|
661
870
|
getTracks(): MediaStreamTrack[] {
|
|
662
871
|
return [...this._tracks];
|
|
663
872
|
}
|
|
@@ -692,3 +901,78 @@ function installStubMediaStream(): void {
|
|
|
692
901
|
}
|
|
693
902
|
(globalThis as unknown as { MediaStream: unknown }).MediaStream = StubMediaStream;
|
|
694
903
|
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
|
|
907
|
+
*
|
|
908
|
+
* jsdom implements no MediaRecorder, so the recording controller can't run
|
|
909
|
+
* headlessly without this. On `stop()` it synchronously flushes one canned
|
|
910
|
+
* Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
|
|
911
|
+
* `stop`, closing the start -> stop -> Blob path the controller drives.
|
|
912
|
+
*
|
|
913
|
+
* `isTypeSupported` is controllable so tests exercise mime negotiation:
|
|
914
|
+
* - `supportedTypes = null` (default): support everything, so negotiation
|
|
915
|
+
* picks the mp4 preference.
|
|
916
|
+
* - `supportedTypes = ["video/webm"]`: force the webm fallback.
|
|
917
|
+
* Reset `supportedTypes` back to null between tests that change it.
|
|
918
|
+
*/
|
|
919
|
+
export class StubMediaRecorder {
|
|
920
|
+
/** Types isTypeSupported reports available; null = everything. */
|
|
921
|
+
static supportedTypes: string[] | null = null;
|
|
922
|
+
|
|
923
|
+
static isTypeSupported(type: string): boolean {
|
|
924
|
+
return StubMediaRecorder.supportedTypes === null
|
|
925
|
+
? true
|
|
926
|
+
: StubMediaRecorder.supportedTypes.includes(type);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
readonly stream: MediaStream;
|
|
930
|
+
readonly mimeType: string;
|
|
931
|
+
state: "inactive" | "recording" | "paused" = "inactive";
|
|
932
|
+
|
|
933
|
+
private readonly _listeners = new Map<string, Set<(e: { data?: Blob }) => void>>();
|
|
934
|
+
|
|
935
|
+
constructor(stream: MediaStream, options?: { mimeType?: string }) {
|
|
936
|
+
this.stream = stream;
|
|
937
|
+
this.mimeType = options?.mimeType ?? "";
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
addEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
|
|
941
|
+
let set = this._listeners.get(type);
|
|
942
|
+
if (!set) {
|
|
943
|
+
set = new Set();
|
|
944
|
+
this._listeners.set(type, set);
|
|
945
|
+
}
|
|
946
|
+
set.add(cb);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
removeEventListener(type: string, cb: (e: { data?: Blob }) => void): void {
|
|
950
|
+
this._listeners.get(type)?.delete(cb);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private _emit(type: string, e: { data?: Blob }): void {
|
|
954
|
+
this._listeners.get(type)?.forEach((cb) => cb(e));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
start(_timeslice?: number): void {
|
|
958
|
+
this.state = "recording";
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
|
|
962
|
+
stop(): void {
|
|
963
|
+
if (this.state === "inactive") return;
|
|
964
|
+
this.state = "inactive";
|
|
965
|
+
const bytes = new Uint8Array(2048);
|
|
966
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = i & 0xff;
|
|
967
|
+
const data = new Blob([bytes], { type: this.mimeType || "video/webm" });
|
|
968
|
+
this._emit("dataavailable", { data });
|
|
969
|
+
this._emit("stop", {});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Emit a partial data chunk without stopping (matches the real API). */
|
|
973
|
+
requestData(): void {
|
|
974
|
+
if (this.state !== "recording") return;
|
|
975
|
+
const data = new Blob([new Uint8Array(512)], { type: this.mimeType || "video/webm" });
|
|
976
|
+
this._emit("dataavailable", { data });
|
|
977
|
+
}
|
|
978
|
+
}
|
package/src/testing.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { CameraLifecycle, Layer } from "./__generated__/types";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
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] });
|
|
@@ -154,3 +196,149 @@ describe("MockSidecar", () => {
|
|
|
154
196
|
expect(states).toEqual(["connected", "failed"]);
|
|
155
197
|
});
|
|
156
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("MockSidecar force-full-resolution", () => {
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
vi.useRealTimers();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("bumps render/operator size to the ceiling after a delay, then reverts on release", async () => {
|
|
206
|
+
vi.useFakeTimers();
|
|
207
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 100 });
|
|
208
|
+
sidecar.addCamera({
|
|
209
|
+
flightId: 42,
|
|
210
|
+
renderWidth: 640,
|
|
211
|
+
renderHeight: 360,
|
|
212
|
+
operatorWidth: 640,
|
|
213
|
+
operatorHeight: 360,
|
|
214
|
+
maxWidth: 1920,
|
|
215
|
+
maxHeight: 1080,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const client = new KerbcastClient(
|
|
219
|
+
{ host: "localhost", port: 8088 },
|
|
220
|
+
sidecar.createTransport(),
|
|
221
|
+
);
|
|
222
|
+
await client.connect([42]);
|
|
223
|
+
sidecar.open();
|
|
224
|
+
|
|
225
|
+
await client.camera(42).setForceFullResolution(true);
|
|
226
|
+
// Not yet bumped: the delay hasn't elapsed.
|
|
227
|
+
expect(client.camera(42).state?.operatorWidth).toBe(640);
|
|
228
|
+
|
|
229
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
230
|
+
expect(client.camera(42).state).toMatchObject({
|
|
231
|
+
renderWidth: 1920,
|
|
232
|
+
renderHeight: 1080,
|
|
233
|
+
operatorWidth: 1920,
|
|
234
|
+
operatorHeight: 1080,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await client.camera(42).setForceFullResolution(false);
|
|
238
|
+
expect(client.camera(42).state?.operatorWidth).toBe(1920); // not yet reverted
|
|
239
|
+
|
|
240
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
241
|
+
expect(client.camera(42).state).toMatchObject({
|
|
242
|
+
renderWidth: 640,
|
|
243
|
+
renderHeight: 360,
|
|
244
|
+
operatorWidth: 640,
|
|
245
|
+
operatorHeight: 360,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("defaults the ceiling to the camera's initial operator size when maxWidth/maxHeight are omitted", async () => {
|
|
250
|
+
vi.useFakeTimers();
|
|
251
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 10 });
|
|
252
|
+
sidecar.addCamera({ flightId: 7, operatorWidth: 1280, operatorHeight: 720 });
|
|
253
|
+
|
|
254
|
+
const client = new KerbcastClient(
|
|
255
|
+
{ host: "localhost", port: 8088 },
|
|
256
|
+
sidecar.createTransport(),
|
|
257
|
+
);
|
|
258
|
+
await client.connect([7]);
|
|
259
|
+
sidecar.open();
|
|
260
|
+
|
|
261
|
+
await client.camera(7).setForceFullResolution(true);
|
|
262
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
263
|
+
|
|
264
|
+
expect(client.camera(7).state).toMatchObject({
|
|
265
|
+
renderWidth: 1280,
|
|
266
|
+
renderHeight: 720,
|
|
267
|
+
operatorWidth: 1280,
|
|
268
|
+
operatorHeight: 720,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("cancels a pending bump when force is released before the delay elapses", async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
const sidecar = new MockSidecar({ forceBumpDelayMs: 100 });
|
|
275
|
+
sidecar.addCamera({
|
|
276
|
+
flightId: 42,
|
|
277
|
+
operatorWidth: 640,
|
|
278
|
+
operatorHeight: 360,
|
|
279
|
+
maxWidth: 1920,
|
|
280
|
+
maxHeight: 1080,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const client = new KerbcastClient(
|
|
284
|
+
{ host: "localhost", port: 8088 },
|
|
285
|
+
sidecar.createTransport(),
|
|
286
|
+
);
|
|
287
|
+
await client.connect([42]);
|
|
288
|
+
sidecar.open();
|
|
289
|
+
|
|
290
|
+
await client.camera(42).setForceFullResolution(true);
|
|
291
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
292
|
+
await client.camera(42).setForceFullResolution(false);
|
|
293
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
294
|
+
|
|
295
|
+
// Never bumped, so nothing to revert: still the original size.
|
|
296
|
+
expect(client.camera(42).state).toMatchObject({
|
|
297
|
+
operatorWidth: 640,
|
|
298
|
+
operatorHeight: 360,
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("MockSidecar.discoveredCameras / client.discover()", () => {
|
|
304
|
+
it("reports maxWidth/maxHeight from each camera's init", () => {
|
|
305
|
+
const sidecar = new MockSidecar();
|
|
306
|
+
sidecar.addCamera({
|
|
307
|
+
flightId: 1,
|
|
308
|
+
cameraName: "NavCam",
|
|
309
|
+
operatorWidth: 1280,
|
|
310
|
+
operatorHeight: 720,
|
|
311
|
+
maxWidth: 1920,
|
|
312
|
+
maxHeight: 1080,
|
|
313
|
+
});
|
|
314
|
+
sidecar.addCamera({ flightId: 2, operatorWidth: 640, operatorHeight: 360 });
|
|
315
|
+
|
|
316
|
+
const discovered = sidecar.discoveredCameras();
|
|
317
|
+
expect(discovered.find((c) => c.flightId === 1)).toMatchObject({
|
|
318
|
+
maxWidth: 1920,
|
|
319
|
+
maxHeight: 1080,
|
|
320
|
+
});
|
|
321
|
+
// No explicit ceiling: falls back to the camera's initial operator size.
|
|
322
|
+
expect(discovered.find((c) => c.flightId === 2)).toMatchObject({
|
|
323
|
+
maxWidth: 640,
|
|
324
|
+
maxHeight: 360,
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("populates client.camera(id).maxRenderSize via client.discover()", async () => {
|
|
329
|
+
const sidecar = new MockSidecar();
|
|
330
|
+
sidecar.addCamera({ flightId: 42, maxWidth: 1920, maxHeight: 1080 });
|
|
331
|
+
|
|
332
|
+
const client = new KerbcastClient(
|
|
333
|
+
{ host: "localhost", port: 8088 },
|
|
334
|
+
sidecar.createTransport(),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
338
|
+
new Response(JSON.stringify({ cameras: sidecar.discoveredCameras() }), { status: 200 }),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
await client.discover();
|
|
342
|
+
expect(client.camera(42).maxRenderSize).toEqual({ width: 1920, height: 1080 });
|
|
343
|
+
});
|
|
344
|
+
});
|