@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,6 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
2
2
  import { CameraKind, CameraLifecycle, CrewLocation, Layer, QualityPreset, TrackMode } from "../__generated__/types";
3
- import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
3
+ import type { DiscoveredCamera, InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
4
4
  export interface MockCameraInit {
5
5
  flightId: number;
6
6
  lifecycle?: CameraLifecycle;
@@ -39,6 +39,27 @@ export interface MockCameraInit {
39
39
  qualityLimitedBy?: string;
40
40
  /** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
41
41
  trackMode?: TrackMode;
42
+ /**
43
+ * Physical/ceiling render size, served by the mock's `/cameras` discovery
44
+ * intercept (`DiscoveredCamera.maxWidth`/`maxHeight`). Defaults to
45
+ * `operatorWidth`/`operatorHeight` (i.e. already at ceiling) so a camera
46
+ * that never sets these behaves as it always has. Give a camera a ceiling
47
+ * above its initial `operatorWidth`/`operatorHeight` to demonstrate a
48
+ * visible bump when `set-force-full-resolution` lands.
49
+ */
50
+ maxWidth?: number;
51
+ maxHeight?: number;
52
+ }
53
+ /** Options for {@link MockSidecar}'s constructor. */
54
+ export interface MockSidecarOptions {
55
+ /**
56
+ * Delay (ms) before a forced camera's simulated resolution bump lands,
57
+ * once `set-force-full-resolution` (`force: true`) is received, and
58
+ * before the render size reverts on `force: false`. Long enough that a
59
+ * screenshot can catch the resulting "ARMING" state; short enough that
60
+ * automated tests/waits don't feel slow. Defaults to 600ms.
61
+ */
62
+ forceBumpDelayMs?: number;
42
63
  }
43
64
  /**
44
65
  * In-process protocol-level fake for the kerbcast sidecar.
@@ -66,6 +87,14 @@ export declare class MockSidecar {
66
87
  private readonly _cameras;
67
88
  private readonly _commands;
68
89
  private _throttleMainScreen;
90
+ private readonly _forceBumpDelayMs;
91
+ /** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
92
+ private readonly _maxRenderSizes;
93
+ /** Per-flight render size captured just before a force bump, restored on release. */
94
+ private readonly _preForceSize;
95
+ /** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
96
+ private readonly _forceTimers;
97
+ constructor(opts?: MockSidecarOptions);
69
98
  private _openHandler;
70
99
  private _clientMsgHandler;
71
100
  private _stateHandler;
@@ -89,6 +118,14 @@ export declare class MockSidecar {
89
118
  private readonly _deliveredTracks;
90
119
  /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
91
120
  addCamera(init: MockCameraInit): void;
121
+ /**
122
+ * The `DiscoveredCamera[]` the mock's `GET /cameras` intercept serves,
123
+ * mirroring the real sidecar's discovery endpoint. `maxWidth`/`maxHeight`
124
+ * come from each camera's `maxWidth`/`maxHeight` init (or its initial
125
+ * `operatorWidth`/`operatorHeight` when omitted). Used by
126
+ * `client.discover()` to populate `KerbcastCameraHandle.maxRenderSize`.
127
+ */
128
+ discoveredCameras(): DiscoveredCamera[];
92
129
  /**
93
130
  * Returns a `KerbcastTransport` backed by this mock. Pass it as the
94
131
  * second argument to `KerbcastClient`.
@@ -209,6 +246,18 @@ export declare class MockSidecar {
209
246
  private _buildStatsReport;
210
247
  private _sendToClient;
211
248
  private _handleClientMessage;
249
+ /**
250
+ * Simulate the sidecar's `effective_render_size` force branch: after
251
+ * {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
252
+ * ceiling (`force: true`) or restore whatever it was before the force
253
+ * (`force: false`), pushing a `camera-state-changed` either way. The delay
254
+ * models the real round trip (control-file write, plugin capture pickup,
255
+ * WebRTC renegotiation) and gives a UI something to show an "ARMING" state
256
+ * for. A rapid re-toggle cancels the previous pending bump/revert.
257
+ */
258
+ private _setForced;
259
+ /** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
260
+ private _applyRenderSize;
212
261
  }
213
262
  /**
214
263
  * Install jsdom shims needed by kerbcast component tests.
@@ -234,3 +283,41 @@ export declare class MockSidecar {
234
283
  * detection reads prefers-color-scheme through it.
235
284
  */
236
285
  export declare function installDomStubs(): void;
286
+ /**
287
+ * Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
288
+ *
289
+ * jsdom implements no MediaRecorder, so the recording controller can't run
290
+ * headlessly without this. On `stop()` it synchronously flushes one canned
291
+ * Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
292
+ * `stop`, closing the start -> stop -> Blob path the controller drives.
293
+ *
294
+ * `isTypeSupported` is controllable so tests exercise mime negotiation:
295
+ * - `supportedTypes = null` (default): support everything, so negotiation
296
+ * picks the mp4 preference.
297
+ * - `supportedTypes = ["video/webm"]`: force the webm fallback.
298
+ * Reset `supportedTypes` back to null between tests that change it.
299
+ */
300
+ export declare class StubMediaRecorder {
301
+ /** Types isTypeSupported reports available; null = everything. */
302
+ static supportedTypes: string[] | null;
303
+ static isTypeSupported(type: string): boolean;
304
+ readonly stream: MediaStream;
305
+ readonly mimeType: string;
306
+ state: "inactive" | "recording" | "paused";
307
+ private readonly _listeners;
308
+ constructor(stream: MediaStream, options?: {
309
+ mimeType?: string;
310
+ });
311
+ addEventListener(type: string, cb: (e: {
312
+ data?: Blob;
313
+ }) => void): void;
314
+ removeEventListener(type: string, cb: (e: {
315
+ data?: Blob;
316
+ }) => void): void;
317
+ private _emit;
318
+ start(_timeslice?: number): void;
319
+ /** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
320
+ stop(): void;
321
+ /** Emit a partial data chunk without stopping (matches the real API). */
322
+ requestData(): void;
323
+ }
@@ -71,10 +71,16 @@ function scaleDim(operatorDim, scale) {
71
71
  * ```
72
72
  */
73
73
  export class MockSidecar {
74
- constructor() {
74
+ constructor(opts = {}) {
75
75
  this._cameras = new Map();
76
76
  this._commands = [];
77
77
  this._throttleMainScreen = false;
78
+ /** Per-flight ceiling (ceiling render size), served by `discoveredCameras()`. */
79
+ this._maxRenderSizes = new Map();
80
+ /** Per-flight render size captured just before a force bump, restored on release. */
81
+ this._preForceSize = new Map();
82
+ /** Per-flight pending bump/revert timer, so a rapid force/unforce toggle cancels the stale one. */
83
+ this._forceTimers = new Map();
78
84
  this._trackIdx = 0;
79
85
  /** Slot mids available for the dynamic-subscription model. Override with
80
86
  * {@link withSlots} before connecting if a test needs a specific pool. */
@@ -91,10 +97,50 @@ export class MockSidecar {
91
97
  * getStats can synthesize a trackIdentifier matching what the client saw.
92
98
  */
93
99
  this._deliveredTracks = new Map();
100
+ this._forceBumpDelayMs = opts.forceBumpDelayMs ?? 600;
94
101
  }
95
102
  /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
96
103
  addCamera(init) {
97
- 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
+ });
98
144
  }
99
145
  /**
100
146
  * Returns a `KerbcastTransport` backed by this mock. Pass it as the
@@ -185,9 +231,18 @@ export class MockSidecar {
185
231
  * {@link destroyCamera}, which keeps the camera present but `Destroyed`.
186
232
  */
187
233
  setCameras(inits) {
234
+ for (const timer of this._forceTimers.values())
235
+ clearTimeout(timer);
236
+ this._forceTimers.clear();
237
+ this._preForceSize.clear();
188
238
  this._cameras.clear();
239
+ this._maxRenderSizes.clear();
189
240
  for (const init of inits) {
190
241
  this._cameras.set(init.flightId, buildCamera(init));
242
+ this._maxRenderSizes.set(init.flightId, {
243
+ maxWidth: init.maxWidth ?? init.operatorWidth ?? 1280,
244
+ maxHeight: init.maxHeight ?? init.operatorHeight ?? 720,
245
+ });
191
246
  }
192
247
  this._sendToClient({
193
248
  type: "camera-snapshot",
@@ -527,10 +582,70 @@ export class MockSidecar {
527
582
  });
528
583
  break;
529
584
  }
585
+ case "set-force-full-resolution": {
586
+ this._setForced(msg.content.flightId, msg.content.force);
587
+ break;
588
+ }
530
589
  case "disconnect":
531
590
  break;
532
591
  }
533
592
  }
593
+ /**
594
+ * Simulate the sidecar's `effective_render_size` force branch: after
595
+ * {@link _forceBumpDelayMs}, bump the camera's render/operator size to its
596
+ * ceiling (`force: true`) or restore whatever it was before the force
597
+ * (`force: false`), pushing a `camera-state-changed` either way. The delay
598
+ * models the real round trip (control-file write, plugin capture pickup,
599
+ * WebRTC renegotiation) and gives a UI something to show an "ARMING" state
600
+ * for. A rapid re-toggle cancels the previous pending bump/revert.
601
+ */
602
+ _setForced(flightId, force) {
603
+ const pending = this._forceTimers.get(flightId);
604
+ if (pending !== undefined) {
605
+ clearTimeout(pending);
606
+ this._forceTimers.delete(flightId);
607
+ }
608
+ const cam = this._cameras.get(flightId);
609
+ if (!cam)
610
+ return;
611
+ if (force) {
612
+ if (!this._preForceSize.has(flightId)) {
613
+ this._preForceSize.set(flightId, { width: cam.operatorWidth, height: cam.operatorHeight });
614
+ }
615
+ const max = this._maxRenderSizes.get(flightId);
616
+ const timer = setTimeout(() => {
617
+ this._forceTimers.delete(flightId);
618
+ this._applyRenderSize(flightId, max?.maxWidth, max?.maxHeight);
619
+ }, this._forceBumpDelayMs);
620
+ this._forceTimers.set(flightId, timer);
621
+ }
622
+ else {
623
+ const prior = this._preForceSize.get(flightId);
624
+ this._preForceSize.delete(flightId);
625
+ if (!prior)
626
+ return;
627
+ const timer = setTimeout(() => {
628
+ this._forceTimers.delete(flightId);
629
+ this._applyRenderSize(flightId, prior.width, prior.height);
630
+ }, this._forceBumpDelayMs);
631
+ this._forceTimers.set(flightId, timer);
632
+ }
633
+ }
634
+ /** Push a render/operator size onto a camera (both fields alike, i.e. no adaptive shed in effect) and broadcast it. */
635
+ _applyRenderSize(flightId, width, height) {
636
+ const cam = this._cameras.get(flightId);
637
+ if (!cam || width === undefined || height === undefined)
638
+ return;
639
+ const updated = {
640
+ ...cam,
641
+ renderWidth: width,
642
+ renderHeight: height,
643
+ operatorWidth: width,
644
+ operatorHeight: height,
645
+ };
646
+ this._cameras.set(flightId, updated);
647
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
648
+ }
534
649
  }
535
650
  /**
536
651
  * Install jsdom shims needed by kerbcast component tests.
@@ -577,6 +692,14 @@ export function installDomStubs() {
577
692
  // with tracks. Provide a stub that satisfies the basic interface used by
578
693
  // tests (getTracks, addTrack, id).
579
694
  installStubMediaStream();
695
+ // MediaRecorder: jsdom ships none, so the recording controller can't be
696
+ // exercised headlessly without it. The stub emits one canned Blob on stop,
697
+ // closing the start -> stop -> Blob path. Its isTypeSupported is controllable
698
+ // (StubMediaRecorder.supportedTypes) so tests can drive mime negotiation and
699
+ // its fallback.
700
+ if (typeof globalThis.MediaRecorder === "undefined") {
701
+ globalThis.MediaRecorder = StubMediaRecorder;
702
+ }
580
703
  // play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
581
704
  // The noise pipeline awaits video.play() on its internal video element.
582
705
  if (typeof HTMLMediaElement !== "undefined") {
@@ -600,9 +723,18 @@ export function installDomStubs() {
600
723
  }
601
724
  /** Minimal MediaStream stand-in for jsdom environments. */
602
725
  class StubMediaStream {
603
- 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 = []) {
604
735
  this.id = Math.random().toString(36).slice(2);
605
736
  this._tracks = [];
737
+ this._tracks.push(...tracks);
606
738
  }
607
739
  getTracks() {
608
740
  return [...this._tracks];
@@ -641,3 +773,68 @@ function installStubMediaStream() {
641
773
  }
642
774
  globalThis.MediaStream = StubMediaStream;
643
775
  }
776
+ /**
777
+ * Minimal MediaRecorder stand-in for jsdom, installed by {@link installDomStubs}.
778
+ *
779
+ * jsdom implements no MediaRecorder, so the recording controller can't run
780
+ * headlessly without this. On `stop()` it synchronously flushes one canned
781
+ * Blob (a fixed byte pattern tagged with the negotiated mimeType) then fires
782
+ * `stop`, closing the start -> stop -> Blob path the controller drives.
783
+ *
784
+ * `isTypeSupported` is controllable so tests exercise mime negotiation:
785
+ * - `supportedTypes = null` (default): support everything, so negotiation
786
+ * picks the mp4 preference.
787
+ * - `supportedTypes = ["video/webm"]`: force the webm fallback.
788
+ * Reset `supportedTypes` back to null between tests that change it.
789
+ */
790
+ export class StubMediaRecorder {
791
+ static isTypeSupported(type) {
792
+ return StubMediaRecorder.supportedTypes === null
793
+ ? true
794
+ : StubMediaRecorder.supportedTypes.includes(type);
795
+ }
796
+ constructor(stream, options) {
797
+ this.state = "inactive";
798
+ this._listeners = new Map();
799
+ this.stream = stream;
800
+ this.mimeType = options?.mimeType ?? "";
801
+ }
802
+ addEventListener(type, cb) {
803
+ let set = this._listeners.get(type);
804
+ if (!set) {
805
+ set = new Set();
806
+ this._listeners.set(type, set);
807
+ }
808
+ set.add(cb);
809
+ }
810
+ removeEventListener(type, cb) {
811
+ this._listeners.get(type)?.delete(cb);
812
+ }
813
+ _emit(type, e) {
814
+ this._listeners.get(type)?.forEach((cb) => cb(e));
815
+ }
816
+ start(_timeslice) {
817
+ this.state = "recording";
818
+ }
819
+ /** Flush a canned data chunk, then fire `stop`, mirroring the real order. */
820
+ stop() {
821
+ if (this.state === "inactive")
822
+ return;
823
+ this.state = "inactive";
824
+ const bytes = new Uint8Array(2048);
825
+ for (let i = 0; i < bytes.length; i++)
826
+ bytes[i] = i & 0xff;
827
+ const data = new Blob([bytes], { type: this.mimeType || "video/webm" });
828
+ this._emit("dataavailable", { data });
829
+ this._emit("stop", {});
830
+ }
831
+ /** Emit a partial data chunk without stopping (matches the real API). */
832
+ requestData() {
833
+ if (this.state !== "recording")
834
+ return;
835
+ const data = new Blob([new Uint8Array(512)], { type: this.mimeType || "video/webm" });
836
+ this._emit("dataavailable", { data });
837
+ }
838
+ }
839
+ /** Types isTypeSupported reports available; null = everything. */
840
+ StubMediaRecorder.supportedTypes = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -54,5 +54,8 @@
54
54
  "jsdom": "^29.0.2",
55
55
  "typescript": "^5.4.0",
56
56
  "vitest": "^4.1.4"
57
+ },
58
+ "dependencies": {
59
+ "mediabunny": "^1.51.0"
57
60
  }
58
61
  }
@@ -281,6 +281,18 @@ export interface SetDegradePayload {
281
281
  level: number;
282
282
  }
283
283
 
284
+ /**
285
+ * Forces a camera to render at the operator ceiling regardless of
286
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a feed
287
+ * stays forced while any connected viewer forces it, and releases when
288
+ * the last one clears it (or disconnects). Still yields to the adaptive
289
+ * framerate shed; overrides only the demand term.
290
+ */
291
+ export interface SetForceFullResolutionPayload {
292
+ flightId: number;
293
+ force: boolean;
294
+ }
295
+
284
296
  export interface SetFovPayload {
285
297
  flightId: number;
286
298
  fov: number;
@@ -452,6 +464,15 @@ export type ClientMessage =
452
464
  * departing consumer's size is cleared so the max relaxes.
453
465
  */
454
466
  | { type: "report-display-size", content: ReportDisplaySizePayload }
467
+ /**
468
+ * Force a camera to render at the operator ceiling regardless of
469
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a
470
+ * feed stays forced while any connected viewer forces it, and
471
+ * releases when the last one clears it (or disconnects). Still
472
+ * yields to the adaptive framerate shed; overrides only the demand
473
+ * term, never the perf-protection ceiling.
474
+ */
475
+ | { type: "set-force-full-resolution", content: SetForceFullResolutionPayload }
455
476
  /**
456
477
  * Set the camera's field-of-view (degrees). Silently ignored for
457
478
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -382,6 +382,30 @@ describe("KerbcastClient", () => {
382
382
  ]);
383
383
  });
384
384
 
385
+ it("set-force-full-resolution routes onto the control channel via the handle", async () => {
386
+ const { transport, captured } = makeFakeTransport();
387
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
388
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
389
+
390
+ await client.connect();
391
+ captured.dc?._open();
392
+ captured.dc!.sent.length = 0; // drop the hello
393
+
394
+ await client.camera(42).setForceFullResolution(true);
395
+ await client.camera(42).setForceFullResolution(false);
396
+
397
+ expect(captured.dc?.sent).toEqual([
398
+ JSON.stringify({
399
+ type: "set-force-full-resolution",
400
+ content: { flightId: 42, force: true },
401
+ }),
402
+ JSON.stringify({
403
+ type: "set-force-full-resolution",
404
+ content: { flightId: 42, force: false },
405
+ }),
406
+ ]);
407
+ });
408
+
385
409
  it("reportDisplaySize sends a report-display-size command", async () => {
386
410
  const { transport, captured } = makeFakeTransport();
387
411
  const client = new KerbcastClient({ host: "h", port: 1 }, transport);
package/src/client.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./__generated__/types";
12
12
  import { CameraLifecycle, ErrorSource } from "./__generated__/types";
13
13
  import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
14
+ import { RecordingController, type RecordingControllerOptions } from "./recording";
14
15
 
15
16
  /** Intensity the static runs at when a camera has no live source (signal lost). */
16
17
  const SOURCELESS_INTENSITY = 1.0;
@@ -47,6 +48,16 @@ export interface KerbcastCameraHandle {
47
48
  readonly stalled: boolean;
48
49
  /** Current {@link setShowStatic} setting. */
49
50
  readonly showStatic: boolean;
51
+ /**
52
+ * The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
53
+ * / `maxHeight`), or `null` until known. Populated from a prior
54
+ * `client.discover()` call (there is no live-pushed field for it), so a
55
+ * consumer that never calls `discover()` sees `null` here indefinitely.
56
+ * Used by `RecordingController` to recognise a force-full-resolution feed
57
+ * that has actually reached its ceiling, as opposed to one merely
58
+ * unchanged since before the force was sent.
59
+ */
60
+ readonly maxRenderSize: { width: number; height: number } | null;
50
61
 
51
62
  setLayers(layers: Layer[]): Promise<void>;
52
63
  setRenderSize(width: number, height: number): Promise<void>;
@@ -78,6 +89,15 @@ export interface KerbcastCameraHandle {
78
89
  * machinery keeps overriding it downward while throttled.
79
90
  */
80
91
  setQuality(preset: QualityPreset | null): Promise<void>;
92
+ /**
93
+ * Force this feed to render at the operator's configured maximum
94
+ * regardless of display size, until released by calling this with
95
+ * `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
96
+ * stays forced while any viewer forces it, and releases when the last one
97
+ * clears it (or disconnects). Still yields to the adaptive framerate
98
+ * shed; it only overrides the display-size demand term.
99
+ */
100
+ setForceFullResolution(force: boolean): Promise<void>;
81
101
  requestKeyframe(): Promise<void>;
82
102
 
83
103
  /**
@@ -150,6 +170,15 @@ export interface KerbcastClientConfig {
150
170
  cameras: number[];
151
171
  slots?: number;
152
172
  }) => Promise<{ sdp: string; cameras: number[] }>;
173
+ /**
174
+ * Options for the lazily-built {@link RecordingController} (`client.recording`).
175
+ * The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
176
+ * default bare `import("mediabunny")` resolving (the sidecar's embedded web
177
+ * page serves its own copy of the package locally, offline/LAN, no CDN)
178
+ * supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
179
+ * this and get the default.
180
+ */
181
+ recording?: RecordingControllerOptions;
153
182
  }
154
183
 
155
184
  /** WebRTC connection state surface. */
@@ -509,6 +538,10 @@ class CameraHandle
509
538
  return this._showStatic;
510
539
  }
511
540
 
541
+ get maxRenderSize(): { width: number; height: number } | null {
542
+ return this.client._maxRenderSize(this.flightId);
543
+ }
544
+
512
545
  configure(options: { noise?: Partial<NoiseConfig> }): void {
513
546
  this._noiseOverride = options.noise ?? null;
514
547
  // Re-evaluate with the new noise setting: rebuild from whatever source
@@ -739,6 +772,13 @@ class CameraHandle
739
772
  });
740
773
  }
741
774
 
775
+ async setForceFullResolution(force: boolean): Promise<void> {
776
+ await this.client._send({
777
+ type: "set-force-full-resolution",
778
+ content: { flightId: this.flightId, force },
779
+ });
780
+ }
781
+
742
782
  async requestKeyframe(): Promise<void> {
743
783
  await this.client._send({
744
784
  type: "request-keyframe",
@@ -816,6 +856,15 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
816
856
  private _warpRate = 1;
817
857
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
818
858
  private _inFlight: boolean | undefined = undefined;
859
+ /** Client-side recording controller, lazily built on first `recording` access. */
860
+ private _recording: RecordingController | null = null;
861
+ /**
862
+ * Physical/ceiling max render size per camera, cached from the last
863
+ * `discover()` call. Empty until `discover()` has been called at least
864
+ * once; a camera absent from the last discovery response is simply
865
+ * missing here (its handle's `maxRenderSize` reads `null`).
866
+ */
867
+ private readonly maxRenderSizeByFlight = new Map<number, { width: number; height: number }>();
819
868
 
820
869
  constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport) {
821
870
  super();
@@ -868,6 +917,17 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
868
917
  };
869
918
  }
870
919
 
920
+ /**
921
+ * Client-side recording controller. Records a camera's already-received
922
+ * track with the browser's `MediaRecorder`, annotated with the mission-time
923
+ * (UT) clock; purely local, no sidecar involvement. Lazily created on first
924
+ * access and stable thereafter. See {@link RecordingController}.
925
+ */
926
+ get recording(): RecordingController {
927
+ if (!this._recording) this._recording = new RecordingController(this, this.cfg.recording);
928
+ return this._recording;
929
+ }
930
+
871
931
  /**
872
932
  * Whether KSP is currently in a flight scene, from the sidecar's
873
933
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -899,9 +959,23 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
899
959
  const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
900
960
  if (!res.ok) throw new Error(`/cameras returned ${res.status}`);
901
961
  const body = (await res.json()) as { cameras: DiscoveredCamera[] };
962
+ for (const cam of body.cameras) {
963
+ this.maxRenderSizeByFlight.set(cam.flightId, {
964
+ width: cam.maxWidth,
965
+ height: cam.maxHeight,
966
+ });
967
+ }
902
968
  return body.cameras;
903
969
  }
904
970
 
971
+ /**
972
+ * Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
973
+ * `discover()` has never been called or never reported this camera.
974
+ */
975
+ _maxRenderSize(flightId: number): { width: number; height: number } | null {
976
+ return this.maxRenderSizeByFlight.get(flightId) ?? null;
977
+ }
978
+
905
979
  /**
906
980
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
907
981
  * `connect` while already connected disconnects first.
package/src/index.ts CHANGED
@@ -27,3 +27,25 @@ export {
27
27
  type PanZoomBounds,
28
28
  type PanZoomControllerOptions,
29
29
  } from "./panZoom";
30
+
31
+ export {
32
+ RecordingController,
33
+ createMediabunnyTrimmerLoader,
34
+ negotiateMimeType,
35
+ commonUtWindow,
36
+ utToRecordingTimeMs,
37
+ DEFAULT_COVERAGE_TIMEOUT_MS,
38
+ ARM_TIMEOUT_MS,
39
+ type ActiveRecordingInfo,
40
+ type RecordingClient,
41
+ type RecordingControllerOptions,
42
+ type RecordingHandle,
43
+ type RecordingsSnapshot,
44
+ type GroupedRecordingHandle,
45
+ type GroupedRecordingStart,
46
+ type GroupTrimmer,
47
+ type TrimmerLoader,
48
+ type UtSample,
49
+ type StartRecordingOptions,
50
+ type StopGroupedRecordingOptions,
51
+ } from "./recording";