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