@ksp-gonogo/kerbcast 1.6.2 → 1.6.3

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.
@@ -131,8 +131,8 @@ export interface CameraState {
131
131
  */
132
132
  targetBitrateBps: number;
133
133
  /**
134
- * Effective degrade level (max across subscribers' SetDegrade
135
- * requests). 0.0 = perfect, 1.0 = max degradation. Applied
134
+ * Effective degrade level (last-writer-wins across subscribers'
135
+ * SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
136
136
  * alongside operator render-size + adaptive shed; the encoder
137
137
  * multiplies its effective bitrate by `(1 - 0.7 * level)` and
138
138
  * skips fan-out for a fraction of frames at high levels.
@@ -187,10 +187,12 @@ export interface SceneStateChangedPayload {
187
187
  export interface SetDegradePayload {
188
188
  flightId: number;
189
189
  /**
190
- * 0.0 = perfect quality, 1.0 = maximum degradation. Caps and
191
- * is per-subscriber: the sidecar applies max across active
192
- * subscribers so the noisiest consumer's request wins (same
193
- * pattern as REMB picking the min bandwidth).
190
+ * 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
191
+ * Last-writer-wins: degrade is a property of the camera's game
192
+ * state (comms strength), shared by all viewers of the single
193
+ * encoded stream, so the sidecar stores the latest value rather
194
+ * than reducing across subscribers. A well-behaved driver
195
+ * re-asserts every tick.
194
196
  */
195
197
  level: number;
196
198
  }
@@ -379,11 +381,12 @@ export type ClientMessage =
379
381
  }
380
382
  /**
381
383
  * Request artificial signal degradation. 0.0 = perfect quality,
382
- * 1.0 = maximum degradation. Per-subscriber: the sidecar
383
- * applies max across active subscribers (slowest consumer
384
- * wins, same pattern as REMB bandwidth). Lets consumers signal
385
- * to the sidecar "I want to look like the in-game CommNet
386
- * antenna is struggling"; sidecar takes the opportunity to
384
+ * 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
385
+ * the latest value on the camera (degrade is shared across all
386
+ * viewers of the one encoded stream), so a departed peer can't
387
+ * pin it. Lets consumers signal to the sidecar "I want to look
388
+ * like the in-game CommNet antenna is struggling"; sidecar takes
389
+ * the opportunity to
387
390
  * reduce bitrate + skip frames, which both creates the
388
391
  * signal-loss aesthetic AND saves encoder CPU.
389
392
  */
package/dist/client.js CHANGED
@@ -4,6 +4,13 @@ import { tryCreateNoisePipeline } from "./noise";
4
4
  const SOURCELESS_INTENSITY = 1.0;
5
5
  /** Floor applied to the degrade-driven intensity of a live feed. */
6
6
  const LIVE_INTENSITY_FLOOR = 0.05;
7
+ /**
8
+ * While a feed is stalled, how often to re-request a keyframe. A stall is
9
+ * usually a wedged decoder that only a fresh IDR clears; the first request or
10
+ * its keyframe can be lost during the same outage, so we keep asking until
11
+ * frames resume.
12
+ */
13
+ const STALL_KEYFRAME_RETRY_MS = 2000;
7
14
  export class BrowserKerbcastTransport {
8
15
  constructor(opts = {}) {
9
16
  this.opts = { iceGatheringTimeoutMs: opts.iceGatheringTimeoutMs ?? 2000 };
@@ -159,6 +166,8 @@ class CameraHandle extends TypedEmitter {
159
166
  this._sourceless = false;
160
167
  /** Stall state mirrored from the pipeline's onStallChange callback. */
161
168
  this._stalled = false;
169
+ /** Interval that re-requests keyframes while stalled; null when not stalled. */
170
+ this._keyframeRetry = null;
162
171
  /** Whether animated static is drawn; survives pipeline rebuilds. */
163
172
  this._showStatic = true;
164
173
  this.flightId = flightId;
@@ -291,6 +300,26 @@ class CameraHandle extends TypedEmitter {
291
300
  return;
292
301
  this._stalled = stalled;
293
302
  this.emit("stall", stalled);
303
+ if (stalled) {
304
+ // A stall usually means the browser decoder is wedged (broken H.264
305
+ // reference chain after packet loss or heavy degrade), which only a
306
+ // fresh keyframe clears. Force one now and keep asking while stalled,
307
+ // rather than waiting on the browser's rate-limited PLI.
308
+ this._requestKeyframeForRecovery();
309
+ if (this._keyframeRetry === null) {
310
+ this._keyframeRetry = setInterval(() => this._requestKeyframeForRecovery(), STALL_KEYFRAME_RETRY_MS);
311
+ }
312
+ }
313
+ else if (this._keyframeRetry !== null) {
314
+ clearInterval(this._keyframeRetry);
315
+ this._keyframeRetry = null;
316
+ }
317
+ }
318
+ _requestKeyframeForRecovery() {
319
+ this.requestKeyframe().catch(() => {
320
+ // Best effort; a failed send (disconnected) is retried on the next tick,
321
+ // or cleared when the stall ends.
322
+ });
294
323
  }
295
324
  _liveIntensity() {
296
325
  return Math.max(LIVE_INTENSITY_FLOOR, this._state?.degradeLevel ?? 0);
@@ -300,6 +329,10 @@ class CameraHandle extends TypedEmitter {
300
329
  * the persistent pipeline, stops its rAF loop, and nulls the stream.
301
330
  */
302
331
  _teardown() {
332
+ if (this._keyframeRetry !== null) {
333
+ clearInterval(this._keyframeRetry);
334
+ this._keyframeRetry = null;
335
+ }
303
336
  this._noisePipeline?.destroy();
304
337
  this._noisePipeline = null;
305
338
  this._rawStream = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
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",
@@ -139,8 +139,8 @@ export interface CameraState {
139
139
  */
140
140
  targetBitrateBps: number;
141
141
  /**
142
- * Effective degrade level (max across subscribers' SetDegrade
143
- * requests). 0.0 = perfect, 1.0 = max degradation. Applied
142
+ * Effective degrade level (last-writer-wins across subscribers'
143
+ * SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
144
144
  * alongside operator render-size + adaptive shed; the encoder
145
145
  * multiplies its effective bitrate by `(1 - 0.7 * level)` and
146
146
  * skips fan-out for a fraction of frames at high levels.
@@ -203,10 +203,12 @@ export interface SceneStateChangedPayload {
203
203
  export interface SetDegradePayload {
204
204
  flightId: number;
205
205
  /**
206
- * 0.0 = perfect quality, 1.0 = maximum degradation. Caps and
207
- * is per-subscriber: the sidecar applies max across active
208
- * subscribers so the noisiest consumer's request wins (same
209
- * pattern as REMB picking the min bandwidth).
206
+ * 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
207
+ * Last-writer-wins: degrade is a property of the camera's game
208
+ * state (comms strength), shared by all viewers of the single
209
+ * encoded stream, so the sidecar stores the latest value rather
210
+ * than reducing across subscribers. A well-behaved driver
211
+ * re-asserts every tick.
210
212
  */
211
213
  level: number;
212
214
  }
@@ -385,11 +387,12 @@ export type ClientMessage =
385
387
  | { type: "set-zoom-rate", content: SetZoomRatePayload }
386
388
  /**
387
389
  * Request artificial signal degradation. 0.0 = perfect quality,
388
- * 1.0 = maximum degradation. Per-subscriber: the sidecar
389
- * applies max across active subscribers (slowest consumer
390
- * wins, same pattern as REMB bandwidth). Lets consumers signal
391
- * to the sidecar "I want to look like the in-game CommNet
392
- * antenna is struggling"; sidecar takes the opportunity to
390
+ * 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
391
+ * the latest value on the camera (degrade is shared across all
392
+ * viewers of the one encoded stream), so a departed peer can't
393
+ * pin it. Lets consumers signal to the sidecar "I want to look
394
+ * like the in-game CommNet antenna is struggling"; sidecar takes
395
+ * the opportunity to
393
396
  * reduce bitrate + skip frames, which both creates the
394
397
  * signal-loss aesthetic AND saves encoder CPU.
395
398
  */
package/src/client.ts CHANGED
@@ -15,6 +15,13 @@ import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
15
15
  const SOURCELESS_INTENSITY = 1.0;
16
16
  /** Floor applied to the degrade-driven intensity of a live feed. */
17
17
  const LIVE_INTENSITY_FLOOR = 0.05;
18
+ /**
19
+ * While a feed is stalled, how often to re-request a keyframe. A stall is
20
+ * usually a wedged decoder that only a fresh IDR clears; the first request or
21
+ * its keyframe can be lost during the same outage, so we keep asking until
22
+ * frames resume.
23
+ */
24
+ const STALL_KEYFRAME_RETRY_MS = 2000;
18
25
 
19
26
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
20
27
  export interface NoiseConfig {
@@ -473,6 +480,8 @@ class CameraHandle
473
480
  private _sourceless = false;
474
481
  /** Stall state mirrored from the pipeline's onStallChange callback. */
475
482
  private _stalled = false;
483
+ /** Interval that re-requests keyframes while stalled; null when not stalled. */
484
+ private _keyframeRetry: ReturnType<typeof setInterval> | null = null;
476
485
  /** Whether animated static is drawn; survives pipeline rebuilds. */
477
486
  private _showStatic = true;
478
487
  private readonly client: KerbcastClient;
@@ -625,6 +634,29 @@ class CameraHandle
625
634
  if (this._stalled === stalled) return;
626
635
  this._stalled = stalled;
627
636
  this.emit("stall", stalled);
637
+ if (stalled) {
638
+ // A stall usually means the browser decoder is wedged (broken H.264
639
+ // reference chain after packet loss or heavy degrade), which only a
640
+ // fresh keyframe clears. Force one now and keep asking while stalled,
641
+ // rather than waiting on the browser's rate-limited PLI.
642
+ this._requestKeyframeForRecovery();
643
+ if (this._keyframeRetry === null) {
644
+ this._keyframeRetry = setInterval(
645
+ () => this._requestKeyframeForRecovery(),
646
+ STALL_KEYFRAME_RETRY_MS,
647
+ );
648
+ }
649
+ } else if (this._keyframeRetry !== null) {
650
+ clearInterval(this._keyframeRetry);
651
+ this._keyframeRetry = null;
652
+ }
653
+ }
654
+
655
+ private _requestKeyframeForRecovery(): void {
656
+ this.requestKeyframe().catch(() => {
657
+ // Best effort; a failed send (disconnected) is retried on the next tick,
658
+ // or cleared when the stall ends.
659
+ });
628
660
  }
629
661
 
630
662
  private _liveIntensity(): number {
@@ -636,6 +668,10 @@ class CameraHandle
636
668
  * the persistent pipeline, stops its rAF loop, and nulls the stream.
637
669
  */
638
670
  _teardown(): void {
671
+ if (this._keyframeRetry !== null) {
672
+ clearInterval(this._keyframeRetry);
673
+ this._keyframeRetry = null;
674
+ }
639
675
  this._noisePipeline?.destroy();
640
676
  this._noisePipeline = null;
641
677
  this._rawStream = null;