@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.
- package/dist/__generated__/types.d.ts +45 -0
- package/dist/client.d.ts +23 -1
- package/dist/client.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/noise.js +11 -0
- package/dist/recording.d.ts +113 -59
- package/dist/recording.js +297 -129
- package/package.json +1 -1
- package/src/__generated__/types.ts +46 -0
- package/src/client.test.ts +103 -0
- package/src/client.ts +29 -0
- package/src/index.ts +2 -0
- package/src/noise.test.ts +40 -0
- package/src/noise.ts +13 -0
- package/src/recording.test.ts +391 -59
- package/src/recording.ts +359 -150
|
@@ -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 {
|
package/src/client.test.ts
CHANGED
|
@@ -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;
|