@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.
@@ -1,6 +1,7 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, ServerMessage, SettingsStatePayload } from "../__generated__/types";
2
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,
@@ -46,6 +47,16 @@ export interface MockCameraInit {
46
47
  qualityLimitedBy?: string;
47
48
  /** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
48
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;
49
60
  }
50
61
 
51
62
  function buildCamera(init: MockCameraInit): CameraState {
@@ -100,6 +111,18 @@ function scaleDim(operatorDim: number, scale: number): number {
100
111
  return v < 2 ? 2 : v;
101
112
  }
102
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
+
103
126
  /**
104
127
  * In-process protocol-level fake for the kerbcast sidecar.
105
128
  *
@@ -126,6 +149,17 @@ export class MockSidecar {
126
149
  private readonly _cameras = new Map<number, CameraState>();
127
150
  private readonly _commands: ClientMessage[] = [];
128
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
+ }
129
163
 
130
164
  private _openHandler: (() => void) | undefined;
131
165
  private _clientMsgHandler: ((raw: string) => void) | undefined;
@@ -155,7 +189,47 @@ export class MockSidecar {
155
189
 
156
190
  /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
157
191
  addCamera(init: MockCameraInit): void {
158
- this._cameras.set(init.flightId, buildCamera(init));
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
+ });
159
233
  }
160
234
 
161
235
  /**
@@ -250,9 +324,17 @@ export class MockSidecar {
250
324
  * {@link destroyCamera}, which keeps the camera present but `Destroyed`.
251
325
  */
252
326
  setCameras(inits: MockCameraInit[]): void {
327
+ for (const timer of this._forceTimers.values()) clearTimeout(timer);
328
+ this._forceTimers.clear();
329
+ this._preForceSize.clear();
253
330
  this._cameras.clear();
331
+ this._maxRenderSizes.clear();
254
332
  for (const init of inits) {
255
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
+ });
256
338
  }
257
339
  this._sendToClient({
258
340
  type: "camera-snapshot",
@@ -620,10 +702,70 @@ export class MockSidecar {
620
702
  });
621
703
  break;
622
704
  }
705
+ case "set-force-full-resolution": {
706
+ this._setForced(msg.content.flightId, msg.content.force);
707
+ break;
708
+ }
623
709
  case "disconnect":
624
710
  break;
625
711
  }
626
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
+ }
627
769
  }
628
770
 
629
771
  /**
@@ -676,6 +818,15 @@ export function installDomStubs(): void {
676
818
  // tests (getTracks, addTrack, id).
677
819
  installStubMediaStream();
678
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
+
679
830
  // play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
680
831
  // The noise pipeline awaits video.play() on its internal video element.
681
832
  if (typeof HTMLMediaElement !== "undefined") {
@@ -704,6 +855,18 @@ class StubMediaStream {
704
855
  readonly id: string = Math.random().toString(36).slice(2);
705
856
  private readonly _tracks: MediaStreamTrack[] = [];
706
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
+
707
870
  getTracks(): MediaStreamTrack[] {
708
871
  return [...this._tracks];
709
872
  }
@@ -738,3 +901,78 @@ function installStubMediaStream(): void {
738
901
  }
739
902
  (globalThis as unknown as { MediaStream: unknown }).MediaStream = StubMediaStream;
740
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
+ }
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import { CameraLifecycle, Layer, TrackMode } from "./__generated__/types";
3
3
  import { KerbcastClient } from "./index";
4
4
  import { MockSidecar } from "./testing/index";
@@ -196,3 +196,149 @@ describe("MockSidecar", () => {
196
196
  expect(states).toEqual(["connected", "failed"]);
197
197
  });
198
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
+ });