@ksp-gonogo/kerbcast 1.10.0 → 1.10.2

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.
@@ -111,6 +111,14 @@ export enum QualityPreset {
111
111
  Quarter = "quarter",
112
112
  }
113
113
 
114
+ /** The per-dimension MAX display size held for a camera across consumers. */
115
+ export interface DisplaySizeReport {
116
+ width: number;
117
+ height: number;
118
+ /** Milliseconds since the most recent report that contributed to the max. */
119
+ ageMs: number;
120
+ }
121
+
114
122
  /**
115
123
  * Per-camera snapshot pushed by the sidecar on every state change
116
124
  * (operator API call, adaptive shed, vessel change). Same shape served
@@ -250,6 +258,44 @@ export interface CameraState {
250
258
  * Absent/None = the request is fully honored.
251
259
  */
252
260
  qualityLimitedBy?: string;
261
+ /**
262
+ * Rate in fps Kerbcast is currently pacing this camera's capture to: the
263
+ * plugin's primary rate (MaxCaptureFps) while displayed, the background
264
+ * rate while subscribed but undisplayed, 0 when not capturing.
265
+ */
266
+ targetCaptureFps?: number;
267
+ /**
268
+ * Whether any consumer reports a non-zero display size for this camera,
269
+ * or a consumer forces it (e.g. a full-resolution recording).
270
+ */
271
+ displayed?: boolean;
272
+ /**
273
+ * Frames the plugin produced into this camera's ring per second, over
274
+ * roughly the last two seconds, as observed by the sidecar's encode loop.
275
+ */
276
+ captureFps?: number;
277
+ /**
278
+ * Encoded frames handed to this camera's tracks per second, over the same
279
+ * window. Below `capture_fps` when the encoder skips frames (encode pacing,
280
+ * degrade); a viewer receiving less than this is losing frames in transit.
281
+ */
282
+ sentFps?: number;
283
+ /**
284
+ * Keyframes the encoder emitted for this camera (periodic or requested)
285
+ * since its ring was attached.
286
+ */
287
+ keyframesSent?: number;
288
+ /**
289
+ * PLI/FIR keyframe requests received from viewers for this camera, across
290
+ * peers, since its ring was attached. Counted before the per-slot debounce,
291
+ * so it can exceed the keyframes forced in response.
292
+ */
293
+ keyframeRequestsReceived?: number;
294
+ /**
295
+ * Largest display size currently held for this camera across consumers.
296
+ * Absent when no consumer reports one (`GET /cameras` sends null instead).
297
+ */
298
+ displaySize?: DisplaySizeReport;
253
299
  }
254
300
 
255
301
  export interface CameraSnapshotPayload {
@@ -214,6 +214,23 @@ describe("KerbcastClient", () => {
214
214
  expect(captured.dc?.sent).toContain(JSON.stringify({ type: "hello" }));
215
215
  });
216
216
 
217
+ it("emits `control-open` once per control channel open, after hello is sent", async () => {
218
+ const { transport, captured } = makeFakeTransport();
219
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
220
+ vi.spyOn(globalThis, "fetch").mockImplementation(async () => fakeAnswer());
221
+ const sentAtOpen: string[][] = [];
222
+ client.on("control-open", () => sentAtOpen.push([...(captured.dc?.sent ?? [])]));
223
+
224
+ await client.connect();
225
+ expect(sentAtOpen).toHaveLength(0);
226
+ captured.dc?._open();
227
+ expect(sentAtOpen).toEqual([[JSON.stringify({ type: "hello" })]]);
228
+
229
+ await client.connect();
230
+ captured.dc?._open();
231
+ expect(sentAtOpen).toHaveLength(2);
232
+ });
233
+
217
234
  it("populates the camera registry from a snapshot push", async () => {
218
235
  const { transport, captured } = makeFakeTransport();
219
236
  const client = new KerbcastClient({ host: "h", port: 1 }, transport);
@@ -510,6 +527,92 @@ describe("KerbcastClient", () => {
510
527
  expect(result[0].supportsZoom).toBe(true);
511
528
  });
512
529
 
530
+ it("discover() surfaces per-camera rates and the held display size", async () => {
531
+ const { transport } = makeFakeTransport();
532
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
533
+ const base = {
534
+ partName: "navCam1",
535
+ partTitle: "NavCam",
536
+ cameraName: "NavCam",
537
+ vesselName: "Test Vessel",
538
+ maxWidth: 1280,
539
+ maxHeight: 720,
540
+ supportsZoom: true,
541
+ fov: 60,
542
+ fovMin: 30,
543
+ fovMax: 100,
544
+ supportsPan: false,
545
+ };
546
+ const mockCameras = [
547
+ {
548
+ ...base,
549
+ flightId: 1,
550
+ targetCaptureFps: 30,
551
+ displayed: true,
552
+ captureFps: 29.5,
553
+ sentFps: 23,
554
+ keyframesSent: 12,
555
+ keyframeRequestsReceived: 4,
556
+ displaySize: { width: 640, height: 360, ageMs: 250 },
557
+ },
558
+ { ...base, flightId: 2, targetCaptureFps: 2, displayed: false, displaySize: null },
559
+ { ...base, flightId: 3 },
560
+ ];
561
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
562
+ new Response(JSON.stringify({ cameras: mockCameras }), { status: 200 }),
563
+ );
564
+
565
+ const [watched, background, old] = await client.discover();
566
+ expect(watched.targetCaptureFps).toBe(30);
567
+ expect(watched.displayed).toBe(true);
568
+ expect(watched.captureFps).toBe(29.5);
569
+ expect(watched.sentFps).toBe(23);
570
+ expect(watched.keyframesSent).toBe(12);
571
+ expect(watched.keyframeRequestsReceived).toBe(4);
572
+ expect(watched.displaySize).toEqual({ width: 640, height: 360, ageMs: 250 });
573
+ expect(background.targetCaptureFps).toBe(2);
574
+ expect(background.displaySize).toBeNull();
575
+ // An older sidecar sends none of the rate fields.
576
+ expect(old.targetCaptureFps).toBeUndefined();
577
+ expect(old.displaySize).toBeUndefined();
578
+ });
579
+
580
+ it("camera state from the snapshot carries the rate fields when the sidecar sends them", async () => {
581
+ const { transport, captured } = makeFakeTransport();
582
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
583
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
584
+
585
+ await client.connect();
586
+ captured.dc?._open();
587
+ captured.dc?._msg(
588
+ JSON.stringify({
589
+ type: "camera-snapshot",
590
+ content: {
591
+ cameras: [
592
+ fakeCameraState(1, {
593
+ targetCaptureFps: 2,
594
+ displayed: false,
595
+ captureFps: 2,
596
+ sentFps: 2,
597
+ keyframesSent: 3,
598
+ keyframeRequestsReceived: 0,
599
+ }),
600
+ fakeCameraState(2),
601
+ ],
602
+ },
603
+ }),
604
+ );
605
+
606
+ const [withRates, legacy] = client.cameras;
607
+ expect(withRates.targetCaptureFps).toBe(2);
608
+ expect(withRates.displayed).toBe(false);
609
+ expect(withRates.sentFps).toBe(2);
610
+ expect(withRates.keyframesSent).toBe(3);
611
+ expect(withRates.displaySize).toBeUndefined();
612
+ expect(legacy.targetCaptureFps).toBeUndefined();
613
+ expect(legacy.captureFps).toBeUndefined();
614
+ });
615
+
513
616
  it("sidecar error message emits error event with correct payload including source", async () => {
514
617
  const { transport, captured } = makeFakeTransport();
515
618
  const client = new KerbcastClient({ host: "h", port: 1 }, transport);
package/src/client.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  AdaptiveShedPayload,
3
3
  CameraState,
4
4
  ClientMessage,
5
+ DisplaySizeReport,
5
6
  ErrorPayload,
6
7
  Layer,
7
8
  QualityPreset,
@@ -247,6 +248,26 @@ export interface DiscoveredCamera {
247
248
  targetBitrateBps: number;
248
249
  /** Effective degrade level (max across active subscribers). 0.0 to 1.0. */
249
250
  degradeLevel: number;
251
+ /*
252
+ * Live rate fields, absent from sidecars that predate them. Poll
253
+ * discover() for current values; the data-channel CameraState carries the
254
+ * same fields as of its last push.
255
+ */
256
+ /** Rate Kerbcast paces this camera's capture to: the primary rate while
257
+ * displayed, the background rate while undisplayed, 0 when not capturing. */
258
+ targetCaptureFps?: number;
259
+ /** Any consumer reports a non-zero display size, or the feed is forced. */
260
+ displayed?: boolean;
261
+ /** Frames the plugin produced per second over roughly the last 2 s. */
262
+ captureFps?: number;
263
+ /** Encoded frames handed to this camera's tracks per second, same window. */
264
+ sentFps?: number;
265
+ /** Keyframes the encoder emitted since the camera's stream started. */
266
+ keyframesSent?: number;
267
+ /** PLI/FIR keyframe requests received across peers since the stream started. */
268
+ keyframeRequestsReceived?: number;
269
+ /** Largest display size held across consumers; null when none is held. */
270
+ displaySize?: DisplaySizeReport | null;
250
271
  }
251
272
 
252
273
  /** Event payloads emitted by a {@link KerbcastClient}. */
@@ -274,6 +295,13 @@ export interface KerbcastClientEvents {
274
295
  * instead of per-camera SIGNAL LOST.
275
296
  */
276
297
  "scene-change": boolean | undefined;
298
+ /**
299
+ * Fired each time the control channel of a freshly (re)connected peer
300
+ * opens, i.e. the earliest moment it can carry messages. Consumers that
301
+ * hold per-peer state on the sidecar (display sizes, subscriptions) re-send
302
+ * it here, because the sidecar forgets a peer's state when it leaves.
303
+ */
304
+ "control-open": undefined;
277
305
  }
278
306
 
279
307
  // ---------------------------------------------------------------------------
@@ -1030,6 +1058,7 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
1030
1058
  this.control = peer.createDataChannel("kerbcast-control");
1031
1059
  this.control.onOpen(() => {
1032
1060
  void this._send({ type: "hello" });
1061
+ this.emit("control-open", undefined);
1033
1062
  });
1034
1063
  this.control.onMessage((raw) => {
1035
1064
  this.handleServerMessage(raw);
package/src/index.ts CHANGED
@@ -36,7 +36,9 @@ export {
36
36
  utToRecordingTimeMs,
37
37
  DEFAULT_COVERAGE_TIMEOUT_MS,
38
38
  ARM_TIMEOUT_MS,
39
+ videoElementDecodedSizeProbe,
39
40
  type ActiveRecordingInfo,
41
+ type DecodedSizeProbe,
40
42
  type RecordingClient,
41
43
  type RecordingControllerOptions,
42
44
  type RecordingHandle,
package/src/noise.test.ts CHANGED
@@ -338,3 +338,43 @@ describe("noise pipeline stall presentation", () => {
338
338
  }
339
339
  });
340
340
  });
341
+
342
+ describe("noise pipeline sizing", () => {
343
+ it("resizes the output canvas when the source's decoded size changes mid-stream", () => {
344
+ const env = installStallEnv();
345
+ let vw = 784;
346
+ let vh = 448;
347
+ const proto = HTMLVideoElement.prototype;
348
+ const origW = Object.getOwnPropertyDescriptor(proto, "videoWidth");
349
+ const origH = Object.getOwnPropertyDescriptor(proto, "videoHeight");
350
+ Object.defineProperty(proto, "videoWidth", { configurable: true, get: () => vw });
351
+ Object.defineProperty(proto, "videoHeight", { configurable: true, get: () => vh });
352
+ let output: HTMLCanvasElement | null = null;
353
+ // @ts-expect-error -- jsdom augmentation
354
+ HTMLCanvasElement.prototype.captureStream = vi.fn(function (this: HTMLCanvasElement) {
355
+ output = this;
356
+ return {} as MediaStream;
357
+ });
358
+ try {
359
+ const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05);
360
+ expect(pipeline).not.toBeNull();
361
+
362
+ env.setNow(1000);
363
+ env.fireFrame();
364
+ env.draw();
365
+ expect([output!.width, output!.height]).toEqual([784, 448]);
366
+
367
+ // The sidecar raises the render size; no second loadedmetadata fires.
368
+ vw = 1024;
369
+ vh = 576;
370
+ env.setNow(1033);
371
+ env.fireFrame();
372
+ env.draw();
373
+ expect([output!.width, output!.height]).toEqual([1024, 576]);
374
+ } finally {
375
+ if (origW) Object.defineProperty(proto, "videoWidth", origW);
376
+ if (origH) Object.defineProperty(proto, "videoHeight", origH);
377
+ env.restore();
378
+ }
379
+ });
380
+ });
package/src/noise.ts CHANGED
@@ -185,6 +185,19 @@ export function tryCreateNoisePipeline(
185
185
  const draw = () => {
186
186
  if (destroyed) return;
187
187
  const now = performance.now();
188
+ /*
189
+ * Follow mid-stream size changes. loadedmetadata fires once per source,
190
+ * so without this the canvas (and the processed stream every consumer
191
+ * displays and records) stays at the first decoded size forever, even
192
+ * after the sidecar raises the render size.
193
+ */
194
+ if (
195
+ video.srcObject &&
196
+ video.videoWidth > 0 &&
197
+ (video.videoWidth !== canvas.width || video.videoHeight !== canvas.height)
198
+ ) {
199
+ onMeta();
200
+ }
188
201
  const cw = canvas.width;
189
202
  const ch = canvas.height;
190
203
  const nw = noiseCanvas.width;