@ksp-gonogo/kerbcast 1.7.0 → 1.8.1

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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 - 2026-07-28
4
+
5
+ Client-side camera recording. The capture itself is pure SDK: the recorder taps
6
+ a track the browser has already received, and the sidecar never learns a
7
+ recording happened. Full-resolution recording is the exception, and it does
8
+ reach the wire: it added the `SetForceFullResolution` control message to the
9
+ protocol, plus the matching force-pin handling in the sidecar's camera
10
+ registry.
11
+
12
+ ### Added
13
+
14
+ - `KerbcastClient.recording`: a `RecordingController` that records a camera's
15
+ already-received WebRTC track with the browser's `MediaRecorder`, entirely
16
+ client-local (the sidecar never learns a recording happened)
17
+ - Per-feed API: `startRecording(flightId, opts?)` returns a client-minted
18
+ `recordingId`; `stopRecording(recordingId)` resolves a `RecordingHandle`;
19
+ `fetchRecording` / `discardRecording` / `isRecording`
20
+ - `RecordingHandle = { recordingId, blob, mimeType, utStart?, utEnd?,
21
+ utSamples, byteSize, durationMs }`, where `utSamples` is a ~1Hz
22
+ (recording-time, UT) series so a consumer can interpolate any frame's
23
+ mission-time near-frame-tight; out of flight it falls back to wall-clock
24
+ timing (`ut` absent) and upgrades automatically once UT appears
25
+ - Mime negotiation prefers `video/mp4;codecs=avc1` where
26
+ `MediaRecorder.isTypeSupported`, else `video/webm;codecs=vp9`, else
27
+ `video/webm`; the chosen type is on the handle. `opts.maxDurationMs` auto-stops
28
+ - Grouped API: `startGroupedRecording(flightIds, opts?)` (all recorders open in
29
+ one tick) and `stopGroupedRecording(groupId, { targetUt? })`. `targetUt`
30
+ enforces a coverage guarantee: finalization waits until every feed's latest
31
+ UT sample passes it, so no feed falls short of the common window
32
+ - `GroupedRecordingHandle = { groupId, recordings, commonUtWindow? }`, where
33
+ `commonUtWindow` is the intersection of the feeds' `[utStart, utEnd]`
34
+ - Grouped clips are physically remux-trimmed to `commonUtWindow` by default
35
+ (keyframe-boundary cut, no re-encode) via a lazily `import()`-ed Mediabunny,
36
+ loaded only on grouped-record start (single recordings stay dependency-free);
37
+ a clip that can't be cut cleanly (webm, or a remux error) degrades to
38
+ metadata-only and never fails the recording. `utSamples` + `commonUtWindow`
39
+ still ride along after trimming so a consumer can align to telemetry
40
+ - Helpers `negotiateMimeType`, `commonUtWindow`, `utToRecordingTimeMs`
41
+ - `StubMediaRecorder`: a jsdom `MediaRecorder` shim installed by
42
+ `installDomStubs`, so the full record -> UT-sample -> stop -> handle path is
43
+ headless-testable (its `isTypeSupported` is controllable for mime tests)
44
+ - `mediabunny` as a dependency, dynamically imported for grouped-clip trimming
45
+ only (never pulled by single recordings or non-recording consumers)
46
+
3
47
  ## 1.7.0 - 2026-07-24
4
48
 
5
49
  Crew face cameras, auto-resolution, and camera targeting.
@@ -260,6 +260,17 @@ export interface SetDegradePayload {
260
260
  */
261
261
  level: number;
262
262
  }
263
+ /**
264
+ * Forces a camera to render at the operator ceiling regardless of
265
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a feed
266
+ * stays forced while any connected viewer forces it, and releases when
267
+ * the last one clears it (or disconnects). Still yields to the adaptive
268
+ * framerate shed; overrides only the demand term.
269
+ */
270
+ export interface SetForceFullResolutionPayload {
271
+ flightId: number;
272
+ force: boolean;
273
+ }
263
274
  export interface SetFovPayload {
264
275
  flightId: number;
265
276
  fov: number;
@@ -435,6 +446,18 @@ export type ClientMessage =
435
446
  type: "report-display-size";
436
447
  content: ReportDisplaySizePayload;
437
448
  }
449
+ /**
450
+ * Force a camera to render at the operator ceiling regardless of
451
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a
452
+ * feed stays forced while any connected viewer forces it, and
453
+ * releases when the last one clears it (or disconnects). Still
454
+ * yields to the adaptive framerate shed; overrides only the demand
455
+ * term, never the perf-protection ceiling.
456
+ */
457
+ | {
458
+ type: "set-force-full-resolution";
459
+ content: SetForceFullResolutionPayload;
460
+ }
438
461
  /**
439
462
  * Set the camera's field-of-view (degrees). Silently ignored for
440
463
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
2
2
  import { CameraLifecycle } from "./__generated__/types";
3
+ import { RecordingController, type RecordingControllerOptions } from "./recording";
3
4
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
4
5
  export interface NoiseConfig {
5
6
  /** When false, the pipeline is bypassed and `mediaStream` is the raw WebRTC track. */
@@ -22,6 +23,19 @@ export interface KerbcastCameraHandle {
22
23
  readonly stalled: boolean;
23
24
  /** Current {@link setShowStatic} setting. */
24
25
  readonly showStatic: boolean;
26
+ /**
27
+ * The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
28
+ * / `maxHeight`), or `null` until known. Populated from a prior
29
+ * `client.discover()` call (there is no live-pushed field for it), so a
30
+ * consumer that never calls `discover()` sees `null` here indefinitely.
31
+ * Used by `RecordingController` to recognise a force-full-resolution feed
32
+ * that has actually reached its ceiling, as opposed to one merely
33
+ * unchanged since before the force was sent.
34
+ */
35
+ readonly maxRenderSize: {
36
+ width: number;
37
+ height: number;
38
+ } | null;
25
39
  setLayers(layers: Layer[]): Promise<void>;
26
40
  setRenderSize(width: number, height: number): Promise<void>;
27
41
  setFov(fov: number): Promise<void>;
@@ -52,6 +66,15 @@ export interface KerbcastCameraHandle {
52
66
  * machinery keeps overriding it downward while throttled.
53
67
  */
54
68
  setQuality(preset: QualityPreset | null): Promise<void>;
69
+ /**
70
+ * Force this feed to render at the operator's configured maximum
71
+ * regardless of display size, until released by calling this with
72
+ * `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
73
+ * stays forced while any viewer forces it, and releases when the last one
74
+ * clears it (or disconnects). Still yields to the adaptive framerate
75
+ * shed; it only overrides the display-size demand term.
76
+ */
77
+ setForceFullResolution(force: boolean): Promise<void>;
55
78
  requestKeyframe(): Promise<void>;
56
79
  /**
57
80
  * Override noise settings for this camera only. Takes precedence over the
@@ -121,6 +144,15 @@ export interface KerbcastClientConfig {
121
144
  sdp: string;
122
145
  cameras: number[];
123
146
  }>;
147
+ /**
148
+ * Options for the lazily-built {@link RecordingController} (`client.recording`).
149
+ * The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
150
+ * default bare `import("mediabunny")` resolving (the sidecar's embedded web
151
+ * page serves its own copy of the package locally, offline/LAN, no CDN)
152
+ * supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
153
+ * this and get the default.
154
+ */
155
+ recording?: RecordingControllerOptions;
124
156
  }
125
157
  /** WebRTC connection state surface. */
126
158
  export type KerbcastConnectionState = "disconnected" | "connecting" | "connected" | "failed";
@@ -328,6 +360,15 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
328
360
  private _warpRate;
329
361
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
330
362
  private _inFlight;
363
+ /** Client-side recording controller, lazily built on first `recording` access. */
364
+ private _recording;
365
+ /**
366
+ * Physical/ceiling max render size per camera, cached from the last
367
+ * `discover()` call. Empty until `discover()` has been called at least
368
+ * once; a camera absent from the last discovery response is simply
369
+ * missing here (its handle's `maxRenderSize` reads `null`).
370
+ */
371
+ private readonly maxRenderSizeByFlight;
331
372
  constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport);
332
373
  get state(): KerbcastConnectionState;
333
374
  get cameras(): ReadonlyArray<CameraState>;
@@ -352,6 +393,13 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
352
393
  epoch: number;
353
394
  warpRate: number;
354
395
  };
396
+ /**
397
+ * Client-side recording controller. Records a camera's already-received
398
+ * track with the browser's `MediaRecorder`, annotated with the mission-time
399
+ * (UT) clock; purely local, no sidecar involvement. Lazily created on first
400
+ * access and stable thereafter. See {@link RecordingController}.
401
+ */
402
+ get recording(): RecordingController;
355
403
  /**
356
404
  * Whether KSP is currently in a flight scene, from the sidecar's
357
405
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -371,6 +419,14 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
371
419
  * to subscribe to before opening a peer connection.
372
420
  */
373
421
  discover(): Promise<DiscoveredCamera[]>;
422
+ /**
423
+ * Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
424
+ * `discover()` has never been called or never reported this camera.
425
+ */
426
+ _maxRenderSize(flightId: number): {
427
+ width: number;
428
+ height: number;
429
+ } | null;
374
430
  /**
375
431
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
376
432
  * `connect` while already connected disconnects first.
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CameraLifecycle, ErrorSource } from "./__generated__/types";
2
2
  import { tryCreateNoisePipeline } from "./noise";
3
+ import { RecordingController } from "./recording";
3
4
  /** Intensity the static runs at when a camera has no live source (signal lost). */
4
5
  const SOURCELESS_INTENSITY = 1.0;
5
6
  /** Floor applied to the degrade-driven intensity of a live feed. */
@@ -185,6 +186,9 @@ class CameraHandle extends TypedEmitter {
185
186
  get showStatic() {
186
187
  return this._showStatic;
187
188
  }
189
+ get maxRenderSize() {
190
+ return this.client._maxRenderSize(this.flightId);
191
+ }
188
192
  configure(options) {
189
193
  this._noiseOverride = options.noise ?? null;
190
194
  // Re-evaluate with the new noise setting: rebuild from whatever source
@@ -390,6 +394,12 @@ class CameraHandle extends TypedEmitter {
390
394
  content: { flightId: this.flightId, preset: preset ?? undefined },
391
395
  });
392
396
  }
397
+ async setForceFullResolution(force) {
398
+ await this.client._send({
399
+ type: "set-force-full-resolution",
400
+ content: { flightId: this.flightId, force },
401
+ });
402
+ }
393
403
  async requestKeyframe() {
394
404
  await this.client._send({
395
405
  type: "request-keyframe",
@@ -465,6 +475,15 @@ export class KerbcastClient extends TypedEmitter {
465
475
  this._warpRate = 1;
466
476
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
467
477
  this._inFlight = undefined;
478
+ /** Client-side recording controller, lazily built on first `recording` access. */
479
+ this._recording = null;
480
+ /**
481
+ * Physical/ceiling max render size per camera, cached from the last
482
+ * `discover()` call. Empty until `discover()` has been called at least
483
+ * once; a camera absent from the last discovery response is simply
484
+ * missing here (its handle's `maxRenderSize` reads `null`).
485
+ */
486
+ this.maxRenderSizeByFlight = new Map();
468
487
  this.cfg = cfg;
469
488
  this.transport =
470
489
  transport ??
@@ -507,6 +526,17 @@ export class KerbcastClient extends TypedEmitter {
507
526
  warpRate: this._warpRate,
508
527
  };
509
528
  }
529
+ /**
530
+ * Client-side recording controller. Records a camera's already-received
531
+ * track with the browser's `MediaRecorder`, annotated with the mission-time
532
+ * (UT) clock; purely local, no sidecar involvement. Lazily created on first
533
+ * access and stable thereafter. See {@link RecordingController}.
534
+ */
535
+ get recording() {
536
+ if (!this._recording)
537
+ this._recording = new RecordingController(this, this.cfg.recording);
538
+ return this._recording;
539
+ }
510
540
  /**
511
541
  * Whether KSP is currently in a flight scene, from the sidecar's
512
542
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -537,8 +567,21 @@ export class KerbcastClient extends TypedEmitter {
537
567
  if (!res.ok)
538
568
  throw new Error(`/cameras returned ${res.status}`);
539
569
  const body = (await res.json());
570
+ for (const cam of body.cameras) {
571
+ this.maxRenderSizeByFlight.set(cam.flightId, {
572
+ width: cam.maxWidth,
573
+ height: cam.maxHeight,
574
+ });
575
+ }
540
576
  return body.cameras;
541
577
  }
578
+ /**
579
+ * Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
580
+ * `discover()` has never been called or never reported this camera.
581
+ */
582
+ _maxRenderSize(flightId) {
583
+ return this.maxRenderSizeByFlight.get(flightId) ?? null;
584
+ }
542
585
  /**
543
586
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
544
587
  * `connect` while already connected disconnects first.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./__generated__/types";
2
2
  export { KerbcastClient, BrowserKerbcastTransport, type BrowserKerbcastTransportOptions, type InboundVideoStats, type KerbcastCameraHandle, type KerbcastClientConfig, type KerbcastConnectionState, type KerbcastClientEvents, type KerbcastCameraEvents, type KerbcastTransport, type KerbcastPeer, type KerbcastDataChannel, type DiscoveredCamera, type NoiseConfig, } from "./client";
3
3
  export { PanZoomController, type PanZoomCommandSink, type PanZoomBounds, type PanZoomControllerOptions, } from "./panZoom";
4
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, type ActiveRecordingInfo, type RecordingClient, type RecordingControllerOptions, type RecordingHandle, type RecordingsSnapshot, type GroupedRecordingHandle, type GroupedRecordingStart, type GroupTrimmer, type TrimmerLoader, type UtSample, type StartRecordingOptions, type StopGroupedRecordingOptions, } from "./recording";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@
5
5
  export * from "./__generated__/types";
6
6
  export { KerbcastClient, BrowserKerbcastTransport, } from "./client";
7
7
  export { PanZoomController, } from "./panZoom";
8
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, } from "./recording";