@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.
@@ -4,6 +4,7 @@ import {
4
4
  ErrorSource,
5
5
  Layer,
6
6
  QualityPreset,
7
+ TrackMode,
7
8
  } from "./__generated__/types";
8
9
  import {
9
10
  BrowserKerbcastTransport,
@@ -381,6 +382,68 @@ describe("KerbcastClient", () => {
381
382
  ]);
382
383
  });
383
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
+
409
+ it("reportDisplaySize sends a report-display-size command", async () => {
410
+ const { transport, captured } = makeFakeTransport();
411
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
412
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
413
+
414
+ await client.connect();
415
+ captured.dc?._open();
416
+ captured.dc!.sent.length = 0;
417
+
418
+ await client.reportDisplaySize(3, 40, 40);
419
+
420
+ expect(captured.dc?.sent).toEqual([
421
+ JSON.stringify({
422
+ type: "report-display-size",
423
+ content: { flightId: 3, width: 40, height: 40 },
424
+ }),
425
+ ]);
426
+ });
427
+
428
+ it("setTrackTarget sends a set-track-target command", async () => {
429
+ const { transport, captured } = makeFakeTransport();
430
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
431
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
432
+
433
+ await client.connect();
434
+ captured.dc?._open();
435
+ captured.dc!.sent.length = 0;
436
+
437
+ await client.setTrackTarget(5, TrackMode.ActiveVessel);
438
+
439
+ expect(captured.dc?.sent).toEqual([
440
+ JSON.stringify({
441
+ type: "set-track-target",
442
+ content: { flightId: 5, mode: TrackMode.ActiveVessel },
443
+ }),
444
+ ]);
445
+ });
446
+
384
447
  it("disconnect tears down peer + emits null MediaStream events", async () => {
385
448
  const { transport, captured } = makeFakeTransport();
386
449
  const client = new KerbcastClient({ host: "h", port: 1 }, transport);
package/src/client.ts CHANGED
@@ -7,9 +7,11 @@ import type {
7
7
  QualityPreset,
8
8
  ServerMessage,
9
9
  SettingsStatePayload,
10
+ TrackMode,
10
11
  } from "./__generated__/types";
11
12
  import { CameraLifecycle, ErrorSource } from "./__generated__/types";
12
13
  import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
14
+ import { RecordingController, type RecordingControllerOptions } from "./recording";
13
15
 
14
16
  /** Intensity the static runs at when a camera has no live source (signal lost). */
15
17
  const SOURCELESS_INTENSITY = 1.0;
@@ -46,6 +48,16 @@ export interface KerbcastCameraHandle {
46
48
  readonly stalled: boolean;
47
49
  /** Current {@link setShowStatic} setting. */
48
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;
49
61
 
50
62
  setLayers(layers: Layer[]): Promise<void>;
51
63
  setRenderSize(width: number, height: number): Promise<void>;
@@ -77,6 +89,15 @@ export interface KerbcastCameraHandle {
77
89
  * machinery keeps overriding it downward while throttled.
78
90
  */
79
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>;
80
101
  requestKeyframe(): Promise<void>;
81
102
 
82
103
  /**
@@ -149,6 +170,15 @@ export interface KerbcastClientConfig {
149
170
  cameras: number[];
150
171
  slots?: number;
151
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;
152
182
  }
153
183
 
154
184
  /** WebRTC connection state surface. */
@@ -508,6 +538,10 @@ class CameraHandle
508
538
  return this._showStatic;
509
539
  }
510
540
 
541
+ get maxRenderSize(): { width: number; height: number } | null {
542
+ return this.client._maxRenderSize(this.flightId);
543
+ }
544
+
511
545
  configure(options: { noise?: Partial<NoiseConfig> }): void {
512
546
  this._noiseOverride = options.noise ?? null;
513
547
  // Re-evaluate with the new noise setting: rebuild from whatever source
@@ -738,6 +772,13 @@ class CameraHandle
738
772
  });
739
773
  }
740
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
+
741
782
  async requestKeyframe(): Promise<void> {
742
783
  await this.client._send({
743
784
  type: "request-keyframe",
@@ -815,6 +856,15 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
815
856
  private _warpRate = 1;
816
857
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
817
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 }>();
818
868
 
819
869
  constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport) {
820
870
  super();
@@ -867,6 +917,17 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
867
917
  };
868
918
  }
869
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
+
870
931
  /**
871
932
  * Whether KSP is currently in a flight scene, from the sidecar's
872
933
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -898,9 +959,23 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
898
959
  const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
899
960
  if (!res.ok) throw new Error(`/cameras returned ${res.status}`);
900
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
+ }
901
968
  return body.cameras;
902
969
  }
903
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
+
904
979
  /**
905
980
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
906
981
  * `connect` while already connected disconnects first.
@@ -1040,6 +1115,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
1040
1115
  await this._send({ type: "unsubscribe", content: { flightId } });
1041
1116
  }
1042
1117
 
1118
+ /**
1119
+ * Report this consumer's own displayed pixel size for a camera, driving the
1120
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
1121
+ * sets the shared render size directly, last-writer-wins), this is a
1122
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
1123
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
1124
+ * feed primitives self-measure and call this; consumers do not report by hand.
1125
+ */
1126
+ async reportDisplaySize(flightId: number, width: number, height: number): Promise<void> {
1127
+ await this._send({ type: "report-display-size", content: { flightId, width, height } });
1128
+ }
1129
+
1130
+ /**
1131
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
1132
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
1133
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
1134
+ * rather than this call (two browsers stay in agreement). While tracking, the
1135
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
1136
+ */
1137
+ async setTrackTarget(flightId: number, mode: TrackMode): Promise<void> {
1138
+ await this._send({ type: "set-track-target", content: { flightId, mode } });
1139
+ }
1140
+
1043
1141
  /**
1044
1142
  * Internal — resolves the effective noise enabled state for a camera,
1045
1143
  * merging the per-camera override (if any) with the client-level default.
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";