@ksp-gonogo/kerbcast 1.4.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.
package/dist/client.js ADDED
@@ -0,0 +1,821 @@
1
+ import { CameraLifecycle, ErrorSource } from "./__generated__/types";
2
+ import { tryCreateNoisePipeline } from "./noise";
3
+ /** Intensity the static runs at when a camera has no live source (signal lost). */
4
+ const SOURCELESS_INTENSITY = 1.0;
5
+ /** Floor applied to the degrade-driven intensity of a live feed. */
6
+ const LIVE_INTENSITY_FLOOR = 0.05;
7
+ export class BrowserKerbcastTransport {
8
+ constructor(opts = {}) {
9
+ this.opts = { iceGatheringTimeoutMs: opts.iceGatheringTimeoutMs ?? 2000 };
10
+ }
11
+ createPeer(iceServers) {
12
+ const pc = new RTCPeerConnection({ iceServers });
13
+ const { iceGatheringTimeoutMs } = this.opts;
14
+ let trackIdx = 0;
15
+ let onTrack = null;
16
+ let onStateChange = null;
17
+ pc.ontrack = (ev) => {
18
+ // ev.transceiver.mid is the stable m-line id the sidecar keys its
19
+ // SlotMap on; it's set by the time ontrack fires (post-answer).
20
+ onTrack?.(ev.track, trackIdx++, ev.transceiver.mid ?? "");
21
+ };
22
+ pc.onconnectionstatechange = () => {
23
+ onStateChange?.(mapPeerState(pc.connectionState));
24
+ };
25
+ return {
26
+ addRecvOnlyTransceiver: () => {
27
+ pc.addTransceiver("video", { direction: "recvonly" });
28
+ },
29
+ createDataChannel: (label) => wrapBrowserDataChannel(pc.createDataChannel(label)),
30
+ onTrack: (h) => {
31
+ onTrack = h;
32
+ },
33
+ onStateChange: (h) => {
34
+ onStateChange = h;
35
+ },
36
+ createOffer: async () => {
37
+ const offer = await pc.createOffer();
38
+ return offer.sdp ?? "";
39
+ },
40
+ setLocalDescription: async (sdp) => {
41
+ await pc.setLocalDescription({ type: "offer", sdp });
42
+ },
43
+ setRemoteAnswer: async (sdp) => {
44
+ await pc.setRemoteDescription({ type: "answer", sdp });
45
+ },
46
+ waitForIceComplete: () => new Promise((resolve) => {
47
+ if (pc.iceGatheringState === "complete") {
48
+ resolve();
49
+ return;
50
+ }
51
+ let timer = null;
52
+ const cleanup = () => {
53
+ pc.removeEventListener("icegatheringstatechange", check);
54
+ if (timer !== null) {
55
+ clearTimeout(timer);
56
+ timer = null;
57
+ }
58
+ };
59
+ const check = () => {
60
+ if (pc.iceGatheringState === "complete") {
61
+ cleanup();
62
+ resolve();
63
+ }
64
+ };
65
+ timer = setTimeout(() => {
66
+ timer = null;
67
+ cleanup();
68
+ resolve();
69
+ }, iceGatheringTimeoutMs);
70
+ pc.addEventListener("icegatheringstatechange", check);
71
+ }),
72
+ localSdp: () => pc.localDescription?.sdp ?? "",
73
+ getStats: () => pc.getStats(null),
74
+ close: () => {
75
+ pc.close();
76
+ },
77
+ };
78
+ }
79
+ }
80
+ function wrapBrowserDataChannel(dc) {
81
+ let onOpen = null;
82
+ let onMessage = null;
83
+ let onClose = null;
84
+ dc.onopen = () => onOpen?.();
85
+ dc.onclose = () => onClose?.();
86
+ dc.onmessage = (ev) => {
87
+ if (typeof ev.data === "string")
88
+ onMessage?.(ev.data);
89
+ };
90
+ return {
91
+ send: (s) => {
92
+ dc.send(s);
93
+ },
94
+ onOpen: (h) => {
95
+ onOpen = h;
96
+ },
97
+ onMessage: (h) => {
98
+ onMessage = h;
99
+ },
100
+ onClose: (h) => {
101
+ onClose = h;
102
+ },
103
+ };
104
+ }
105
+ function mapPeerState(state) {
106
+ switch (state) {
107
+ case "new":
108
+ case "connecting":
109
+ return "connecting";
110
+ case "connected":
111
+ return "connected";
112
+ case "failed":
113
+ return "failed";
114
+ case "disconnected":
115
+ case "closed":
116
+ return "disconnected";
117
+ }
118
+ }
119
+ // No bound on E so consumers can use `interface` declarations
120
+ // (interfaces don't carry an implicit string index signature, so
121
+ // `extends Record<string, unknown>` would reject them).
122
+ class TypedEmitter {
123
+ constructor() {
124
+ this.listeners = new Map();
125
+ }
126
+ on(event, handler) {
127
+ let set = this.listeners.get(event);
128
+ if (!set) {
129
+ set = new Set();
130
+ this.listeners.set(event, set);
131
+ }
132
+ set.add(handler);
133
+ return () => {
134
+ set?.delete(handler);
135
+ };
136
+ }
137
+ emit(event, data) {
138
+ const set = this.listeners.get(event);
139
+ set?.forEach((h) => h(data));
140
+ }
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Camera handle implementation
144
+ // ---------------------------------------------------------------------------
145
+ class CameraHandle extends TypedEmitter {
146
+ constructor(flightId, client) {
147
+ super();
148
+ this._state = null;
149
+ this._mediaStream = null;
150
+ this._rawStream = null;
151
+ this._noisePipeline = null;
152
+ this._noiseOverride = null;
153
+ /**
154
+ * True while the handle has no live source but is still showing static
155
+ * (signal-loss / camera-switch gap). Distinct from a true teardown, where
156
+ * the pipeline is destroyed and `_mediaStream` goes null. While sourceless,
157
+ * intensity is pinned at full and degrade updates must not lower it.
158
+ */
159
+ this._sourceless = false;
160
+ /** Stall state mirrored from the pipeline's onStallChange callback. */
161
+ this._stalled = false;
162
+ /** Whether animated static is drawn; survives pipeline rebuilds. */
163
+ this._showStatic = true;
164
+ this.flightId = flightId;
165
+ this.client = client;
166
+ }
167
+ get state() {
168
+ return this._state;
169
+ }
170
+ get mediaStream() {
171
+ return this._mediaStream;
172
+ }
173
+ get stalled() {
174
+ return this._stalled;
175
+ }
176
+ get showStatic() {
177
+ return this._showStatic;
178
+ }
179
+ configure(options) {
180
+ this._noiseOverride = options.noise ?? null;
181
+ // Re-evaluate with the new noise setting: rebuild from whatever source
182
+ // state we're currently in (live, sourceless, or torn down).
183
+ this._applySource(this._rawStream);
184
+ }
185
+ setShowStatic(enabled) {
186
+ this._showStatic = enabled;
187
+ this._noisePipeline?.setShowStatic(enabled);
188
+ }
189
+ /**
190
+ * Internal — called by the client when CameraState pushes arrive. Returns
191
+ * `true` when this update resurrected the camera (Destroyed -> Active), so
192
+ * the client can re-source the handle from its retained track.
193
+ */
194
+ _setState(state) {
195
+ const prevDestroyed = this._state?.lifecycle === CameraLifecycle.Destroyed;
196
+ this._state = state;
197
+ this.emit("change", state);
198
+ // A destroyed camera stops forwarding frames but its track never ends, so
199
+ // nothing else drives the handle into the sourceless state — do it here.
200
+ if (state.lifecycle === CameraLifecycle.Destroyed) {
201
+ if (!this._sourceless || !prevDestroyed)
202
+ this._applySource(null);
203
+ return false;
204
+ }
205
+ // Live feed: degrade drives intensity. Don't touch a sourceless pipeline,
206
+ // which is pinned at full static.
207
+ if (!this._sourceless) {
208
+ this._noisePipeline?.setIntensity(this._liveIntensity());
209
+ }
210
+ // Resurrection: KSP reused this part.flightID after a revert, so the
211
+ // sidecar tombstoned then re-attached the SAME camera and rebound the SAME
212
+ // track with no new slot-map. The handle went sourceless on destroy and
213
+ // only re-sources on a slot-map, so signal the client to re-source it from
214
+ // the track it still holds.
215
+ return prevDestroyed;
216
+ }
217
+ /**
218
+ * Internal — called by the client when a track arrives or drops.
219
+ * A null stream does NOT tear down: if a pipeline can run, the handle keeps
220
+ * emitting live full-intensity static (signal-loss / camera-switch gap).
221
+ * Use {@link _teardown} for genuine teardown (disconnect / peer failure).
222
+ */
223
+ _setMediaStream(stream) {
224
+ this._applySource(stream);
225
+ }
226
+ /**
227
+ * Drive the persistent pipeline to a given source (or sourceless static when
228
+ * `raw` is null). Creates the pipeline lazily on first need and reuses it
229
+ * thereafter so the output stream stays stable across source swaps.
230
+ */
231
+ _applySource(raw) {
232
+ this._rawStream = raw;
233
+ this._sourceless = raw === null;
234
+ const noiseEnabled = this.client._resolveNoise(this._noiseOverride);
235
+ if (noiseEnabled) {
236
+ // Reuse an existing pipeline; otherwise try to create one.
237
+ if (!this._noisePipeline) {
238
+ const initial = raw ? this._liveIntensity() : SOURCELESS_INTENSITY;
239
+ this._noisePipeline = tryCreateNoisePipeline(raw, initial, {
240
+ showStatic: this._showStatic,
241
+ onStallChange: (stalled) => this._setStalled(stalled),
242
+ });
243
+ }
244
+ else {
245
+ this._noisePipeline.setSource(raw);
246
+ }
247
+ const pipeline = this._noisePipeline;
248
+ if (pipeline) {
249
+ pipeline.setIntensity(raw ? this._liveIntensity() : SOURCELESS_INTENSITY);
250
+ this._setOutput(pipeline.processedStream);
251
+ return;
252
+ }
253
+ }
254
+ else if (this._noisePipeline) {
255
+ // Noise just got disabled — drop the pipeline and fall through to raw.
256
+ this._noisePipeline.destroy();
257
+ this._noisePipeline = null;
258
+ this._setStalled(false);
259
+ }
260
+ // Noise disabled, or captureStream unavailable (no pipeline creatable):
261
+ // expose the raw stream directly, or null when there's no source.
262
+ this._setOutput(raw);
263
+ }
264
+ _setOutput(stream) {
265
+ this._mediaStream = stream;
266
+ this.emit("stream", stream);
267
+ }
268
+ _setStalled(stalled) {
269
+ if (this._stalled === stalled)
270
+ return;
271
+ this._stalled = stalled;
272
+ this.emit("stall", stalled);
273
+ }
274
+ _liveIntensity() {
275
+ return Math.max(LIVE_INTENSITY_FLOOR, this._state?.degradeLevel ?? 0);
276
+ }
277
+ /**
278
+ * Internal — genuine teardown (client disconnect / peer failure). Destroys
279
+ * the persistent pipeline, stops its rAF loop, and nulls the stream.
280
+ */
281
+ _teardown() {
282
+ this._noisePipeline?.destroy();
283
+ this._noisePipeline = null;
284
+ this._rawStream = null;
285
+ this._sourceless = false;
286
+ this._setStalled(false);
287
+ this._setOutput(null);
288
+ }
289
+ async setLayers(layers) {
290
+ await this.client._send({
291
+ type: "set-layers",
292
+ content: { flightId: this.flightId, layers },
293
+ });
294
+ }
295
+ async setRenderSize(width, height) {
296
+ await this.client._send({
297
+ type: "set-render-size",
298
+ content: { flightId: this.flightId, width, height },
299
+ });
300
+ }
301
+ async setFov(fov) {
302
+ await this.client._send({
303
+ type: "set-fov",
304
+ content: { flightId: this.flightId, fov },
305
+ });
306
+ }
307
+ async setPan(yaw, pitch) {
308
+ await this.client._send({
309
+ type: "set-pan",
310
+ content: { flightId: this.flightId, yaw, pitch },
311
+ });
312
+ }
313
+ async setPanRate(yawRate, pitchRate) {
314
+ await this.client._send({
315
+ type: "set-pan-rate",
316
+ content: { flightId: this.flightId, yawRate, pitchRate },
317
+ });
318
+ }
319
+ async setZoomRate(rate) {
320
+ await this.client._send({
321
+ type: "set-zoom-rate",
322
+ content: { flightId: this.flightId, rate },
323
+ });
324
+ }
325
+ async setDegrade(level) {
326
+ await this.client._send({
327
+ type: "set-degrade",
328
+ content: { flightId: this.flightId, level },
329
+ });
330
+ }
331
+ async setQuality(preset) {
332
+ await this.client._send({
333
+ type: "set-quality",
334
+ // null (auto) deserializes to None on the sidecar; the generated
335
+ // payload type marks the field optional, so widen for the wire.
336
+ content: { flightId: this.flightId, preset: preset ?? undefined },
337
+ });
338
+ }
339
+ async requestKeyframe() {
340
+ await this.client._send({
341
+ type: "request-keyframe",
342
+ content: { flightId: this.flightId },
343
+ });
344
+ }
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // KerbcastClient
348
+ // ---------------------------------------------------------------------------
349
+ /**
350
+ * High-level kerbcast sidecar client. Owns the WebRTC peer + the
351
+ * `kerbcast-control` data channel + the per-camera registry + the
352
+ * `MediaStream`s for each subscribed camera.
353
+ *
354
+ * Usage:
355
+ *
356
+ * ```ts
357
+ * const client = new KerbcastClient({ host: "192.168.1.74", port: 8088 });
358
+ * await client.connect();
359
+ *
360
+ * const cam = client.camera(2592004302);
361
+ * await cam.setFov(35);
362
+ * videoEl.srcObject = cam.mediaStream;
363
+ *
364
+ * cam.on("change", (state) => { ... });
365
+ * client.on("adaptive-shed", (event) => { ... });
366
+ * ```
367
+ *
368
+ * Disconnect with {@link disconnect}. The handles returned by
369
+ * `camera()` remain valid across reconnects; their `mediaStream`
370
+ * goes null on disconnect and is replaced when the next connect's
371
+ * tracks arrive.
372
+ */
373
+ export class KerbcastClient extends TypedEmitter {
374
+ constructor(cfg, transport) {
375
+ super();
376
+ this.peer = null;
377
+ this.control = null;
378
+ this._state = "disconnected";
379
+ this._cameras = [];
380
+ this.handles = new Map();
381
+ this.requestedOrder = [];
382
+ /** Set when `connect` is given an explicit slot pool. Switches track
383
+ * routing from legacy index order to mid-keyed dynamic slots. */
384
+ this.dynamicMode = false;
385
+ /** Dynamic mode: transceiver mid -> the live track on that slot. */
386
+ this.trackByMid = new Map();
387
+ /** Dynamic mode: transceiver mid -> the camera bound to that slot
388
+ * (populated by SlotMap -- initial bindings announced on Hello). */
389
+ this.flightByMid = new Map();
390
+ /**
391
+ * Legacy mode: raw incoming track id -> flightId. Populated in handleTrack
392
+ * so inboundVideoStats() can match inbound-rtp report entries by
393
+ * trackIdentifier. Cleared on disconnect. Not used in dynamic mode (which
394
+ * matches by mid instead).
395
+ */
396
+ this.flightByTrackId = new Map();
397
+ /** Sidecar version reported on `Hello`; null before handshake. */
398
+ this._sidecarVersion = null;
399
+ this._encoderBackend = null;
400
+ /** Last-received throttle state from `settings-state`. False until the first push. */
401
+ this._throttleMainScreen = false;
402
+ /**
403
+ * Mission-time capture clock from `settings-state`. `_captureUt` is null
404
+ * until the first clock arrives (and against an old sidecar that never
405
+ * sends one), so consumers treat it as "no clock". `_captureEpoch` bumps
406
+ * on discontinuities (revert / quickload / scene reload); only the change
407
+ * is meaningful. `_warpRate` defaults to 1 when the field is absent.
408
+ */
409
+ this._captureUt = null;
410
+ this._captureEpoch = 0;
411
+ this._warpRate = 1;
412
+ this.cfg = cfg;
413
+ this.transport =
414
+ transport ??
415
+ new BrowserKerbcastTransport({
416
+ iceGatheringTimeoutMs: cfg.iceGatheringTimeoutMs,
417
+ });
418
+ }
419
+ get state() {
420
+ return this._state;
421
+ }
422
+ get cameras() {
423
+ return this._cameras;
424
+ }
425
+ get sidecarVersion() {
426
+ return this._sidecarVersion;
427
+ }
428
+ get encoderBackend() {
429
+ return this._encoderBackend;
430
+ }
431
+ /**
432
+ * Current effective state of the "Throttle KSP main render" setting.
433
+ * Reflects what the plugin has applied (from `settings-state` messages),
434
+ * not just what was last requested. False until the first push arrives.
435
+ */
436
+ get throttleMainScreen() {
437
+ return this._throttleMainScreen;
438
+ }
439
+ /**
440
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
441
+ * `captureUt` is the KSP universal time (seconds) the current video was
442
+ * captured at, or null when no clock is known (old plugin/sidecar, or
443
+ * before the first push). `epoch` bumps on a UT discontinuity so a
444
+ * consumer can flush and resync. `warpRate` lets a consumer interpolate
445
+ * `captureUt` between samples; defaults to 1.
446
+ */
447
+ get clock() {
448
+ return {
449
+ captureUt: this._captureUt,
450
+ epoch: this._captureEpoch,
451
+ warpRate: this._warpRate,
452
+ };
453
+ }
454
+ /**
455
+ * Send a `set-throttle-main-screen` command to the sidecar, which writes
456
+ * `global.control.json` for the plugin to apply. The plugin persists the
457
+ * change in the per-save difficulty parameter. The resulting
458
+ * `settings-state` broadcast reflects the applied value.
459
+ */
460
+ async setThrottleMainScreen(enabled) {
461
+ await this._send({
462
+ type: "set-throttle-main-screen",
463
+ content: { enabled },
464
+ });
465
+ }
466
+ /**
467
+ * Pre-handshake camera discovery via the sidecar's HTTP `/cameras`
468
+ * endpoint. Useful when the consumer wants to pick which cameras
469
+ * to subscribe to before opening a peer connection.
470
+ */
471
+ async discover() {
472
+ const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/cameras`);
473
+ if (!res.ok)
474
+ throw new Error(`/cameras returned ${res.status}`);
475
+ const body = (await res.json());
476
+ return body.cameras;
477
+ }
478
+ /**
479
+ * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
480
+ * `connect` while already connected disconnects first.
481
+ *
482
+ * Two modes:
483
+ * - **Legacy** (no `opts.slots`): one transceiver per requested camera;
484
+ * an empty array subscribes to every currently-known camera. Tracks
485
+ * route by m-line index. Unchanged behaviour.
486
+ * - **Dynamic** (`opts.slots` set): negotiates a pool of `opts.slots`
487
+ * recv-only video slots. `requestedCameras` are the *initial*
488
+ * subscription bound to the first slots; spare slots are filled at
489
+ * runtime via {@link subscribe}. Tracks route by transceiver mid
490
+ * (the sidecar announces bindings via SlotMap), so switching a slot's
491
+ * camera needs no renegotiation.
492
+ */
493
+ async connect(requestedCameras = [], opts = {}) {
494
+ this.disconnect();
495
+ this.setState("connecting");
496
+ this.requestedOrder = [...requestedCameras];
497
+ this.dynamicMode = opts.slots !== undefined;
498
+ this.trackByMid.clear();
499
+ this.flightByMid.clear();
500
+ this.flightByTrackId.clear();
501
+ const peer = this.transport.createPeer(this.cfg.iceServers ?? [{ urls: "stun:stun.l.google.com:19302" }]);
502
+ this.peer = peer;
503
+ // Slot-pool size = the recv-only transceivers we offer. Dynamic mode
504
+ // uses the explicit pool size (spares subscribable at runtime); legacy
505
+ // uses one per requested camera (>=1).
506
+ const slotCount = this.dynamicMode
507
+ ? Math.max(opts.slots ?? 0, 1)
508
+ : Math.max(this.requestedOrder.length, 1);
509
+ for (let i = 0; i < slotCount; i++) {
510
+ peer.addRecvOnlyTransceiver();
511
+ }
512
+ peer.onTrack((track, idx, mid) => this.handleTrack(track, idx, mid));
513
+ peer.onStateChange((s) => {
514
+ this.setState(s);
515
+ if (s === "disconnected" || s === "failed") {
516
+ this.tearDownStreams();
517
+ }
518
+ });
519
+ this.control = peer.createDataChannel("kerbcast-control");
520
+ this.control.onOpen(() => {
521
+ void this._send({ type: "hello" });
522
+ });
523
+ this.control.onMessage((raw) => {
524
+ this.handleServerMessage(raw);
525
+ });
526
+ this.control.onClose(() => {
527
+ // Channel closure usually follows peer disconnect; the peer
528
+ // state-change handler covers the tear-down. Nothing to do here.
529
+ });
530
+ await peer.createOffer().then((sdp) => peer.setLocalDescription(sdp));
531
+ await peer.waitForIceComplete();
532
+ const body = {
533
+ sdp: peer.localSdp(),
534
+ cameras: this.requestedOrder,
535
+ };
536
+ if (this.dynamicMode)
537
+ body.slots = slotCount;
538
+ let answer;
539
+ try {
540
+ answer = await (this.cfg.negotiate
541
+ ? this.cfg.negotiate(body)
542
+ : this.httpNegotiate(body));
543
+ }
544
+ catch (err) {
545
+ this.setState("failed");
546
+ throw err;
547
+ }
548
+ await peer.setRemoteAnswer(answer.sdp);
549
+ // Legacy mode routes by index → requestedOrder, so realign to what the
550
+ // sidecar actually wired. Dynamic mode routes by mid via SlotMap (initial
551
+ // bindings arrive on Hello), so the answer's camera list isn't used.
552
+ if (!this.dynamicMode) {
553
+ this.requestedOrder = answer.cameras;
554
+ }
555
+ }
556
+ /** Default signaling: POST the offer to the sidecar's HTTP `/offer`. */
557
+ async httpNegotiate(offer) {
558
+ const res = await fetch(`http://${this.cfg.host}:${this.cfg.port}/offer`, {
559
+ method: "POST",
560
+ headers: { "Content-Type": "application/json" },
561
+ body: JSON.stringify(offer),
562
+ });
563
+ if (!res.ok)
564
+ throw new Error(`POST /offer returned ${res.status}`);
565
+ return (await res.json());
566
+ }
567
+ disconnect() {
568
+ this.peer?.close();
569
+ this.peer = null;
570
+ this.control = null;
571
+ this.tearDownStreams();
572
+ this.setState("disconnected");
573
+ }
574
+ /**
575
+ * Stable per-flight-id handle. Creates a new handle on first call
576
+ * for a given ID; returns the same handle on subsequent calls.
577
+ */
578
+ camera(flightId) {
579
+ return this.getOrCreateHandle(flightId);
580
+ }
581
+ /**
582
+ * Dynamic mode only: bind a camera to a free slot on the already-connected
583
+ * peer. The camera's stream appears on its handle once the sidecar answers
584
+ * with a SlotMap; the sidecar emits an `error` event if no slot is free. No
585
+ * renegotiation.
586
+ */
587
+ async subscribe(flightId) {
588
+ await this._send({ type: "subscribe", content: { flightId } });
589
+ }
590
+ /**
591
+ * Dynamic mode only: release a camera's slot. Its handle's `mediaStream`
592
+ * goes null once the sidecar confirms with a SlotMap.
593
+ */
594
+ async unsubscribe(flightId) {
595
+ await this._send({ type: "unsubscribe", content: { flightId } });
596
+ }
597
+ /**
598
+ * Internal — resolves the effective noise enabled state for a camera,
599
+ * merging the per-camera override (if any) with the client-level default.
600
+ */
601
+ _resolveNoise(override) {
602
+ if (override?.enabled !== undefined)
603
+ return override.enabled;
604
+ return this.cfg.noise?.enabled !== false; // default: enabled
605
+ }
606
+ /**
607
+ * Internal — sends a typed `ClientMessage` over the control
608
+ * channel. Drops silently if the channel hasn't opened yet (and
609
+ * logs to console). Public on the camera handle, but consumers
610
+ * shouldn't need to call this directly.
611
+ */
612
+ async _send(msg) {
613
+ if (!this.control) {
614
+ throw new Error("[kerbcast] control channel not open");
615
+ }
616
+ this.control.send(JSON.stringify(msg));
617
+ }
618
+ // -- private --
619
+ getOrCreateHandle(flightId) {
620
+ let handle = this.handles.get(flightId);
621
+ if (!handle) {
622
+ handle = new CameraHandle(flightId, this);
623
+ this.handles.set(flightId, handle);
624
+ }
625
+ return handle;
626
+ }
627
+ handleTrack(track, idx, mid) {
628
+ if (this.dynamicMode) {
629
+ // Store the slot's track keyed by mid; bind it to a camera if (or once)
630
+ // a SlotMap names this mid. Track and SlotMap can arrive in either
631
+ // order — whichever lands second triggers the binding.
632
+ this.trackByMid.set(mid, track);
633
+ const flightId = this.flightByMid.get(mid);
634
+ if (flightId !== undefined) {
635
+ this.getOrCreateHandle(flightId)._setMediaStream(new MediaStream([track]));
636
+ }
637
+ return;
638
+ }
639
+ // Legacy: m-line index maps to the requested camera list.
640
+ const flightId = this.requestedOrder[idx];
641
+ if (flightId === undefined)
642
+ return;
643
+ // Record the raw track id so inboundVideoStats() can match it against
644
+ // the report's trackIdentifier field.
645
+ if (track.id)
646
+ this.flightByTrackId.set(track.id, flightId);
647
+ this.getOrCreateHandle(flightId)._setMediaStream(new MediaStream([track]));
648
+ }
649
+ handleServerMessage(raw) {
650
+ let msg;
651
+ try {
652
+ msg = JSON.parse(raw);
653
+ }
654
+ catch (err) {
655
+ this.emit("error", {
656
+ message: err instanceof Error ? err.message : String(err),
657
+ source: ErrorSource.Client,
658
+ });
659
+ return;
660
+ }
661
+ switch (msg.type) {
662
+ case "hello":
663
+ this._sidecarVersion = msg.content.sidecarVersion;
664
+ this._encoderBackend = msg.content.encoderBackend;
665
+ break;
666
+ case "camera-snapshot":
667
+ this._cameras = msg.content.cameras;
668
+ for (const cam of this._cameras) {
669
+ if (this.getOrCreateHandle(cam.flightId)._setState(cam)) {
670
+ this._resourceHandle(cam.flightId);
671
+ }
672
+ }
673
+ this.emit("cameras-change", this._cameras);
674
+ break;
675
+ case "camera-state-changed": {
676
+ const next = this._cameras.filter((c) => c.flightId !== msg.content.state.flightId);
677
+ next.push(msg.content.state);
678
+ next.sort((a, b) => a.flightId - b.flightId);
679
+ this._cameras = next;
680
+ if (this.getOrCreateHandle(msg.content.state.flightId)._setState(msg.content.state)) {
681
+ this._resourceHandle(msg.content.state.flightId);
682
+ }
683
+ this.emit("cameras-change", this._cameras);
684
+ break;
685
+ }
686
+ case "slot-map": {
687
+ const { mid, flightId } = msg.content;
688
+ const prev = this.flightByMid.get(mid);
689
+ if (flightId == null) {
690
+ // Slot freed — the camera that held it loses its stream.
691
+ if (prev !== undefined) {
692
+ this.getOrCreateHandle(prev)._setMediaStream(null);
693
+ }
694
+ this.flightByMid.delete(mid);
695
+ }
696
+ else {
697
+ // A switch (a different camera held this slot) clears the old one.
698
+ if (prev !== undefined && prev !== flightId) {
699
+ this.getOrCreateHandle(prev)._setMediaStream(null);
700
+ }
701
+ this.flightByMid.set(mid, flightId);
702
+ const track = this.trackByMid.get(mid);
703
+ if (track) {
704
+ this.getOrCreateHandle(flightId)._setMediaStream(new MediaStream([track]));
705
+ }
706
+ else {
707
+ // Bound to a slot but the track hasn't arrived yet (the
708
+ // camera-switch gap). Drive the incoming handle sourceless so it
709
+ // shows static rather than going blank until the track lands.
710
+ this.getOrCreateHandle(flightId)._setMediaStream(null);
711
+ }
712
+ }
713
+ break;
714
+ }
715
+ case "adaptive-shed":
716
+ this.emit("adaptive-shed", msg.content);
717
+ break;
718
+ case "error":
719
+ this.emit("error", msg.content);
720
+ break;
721
+ case "ping":
722
+ /*
723
+ * A ping can race a closing channel (disconnect mid-flight); a
724
+ * pong that can't be delivered is moot, not an error.
725
+ */
726
+ void this._send({ type: "pong" }).catch(() => { });
727
+ this.emit("ping", undefined);
728
+ break;
729
+ case "settings-state":
730
+ this._throttleMainScreen = msg.content.throttleMainScreen;
731
+ /*
732
+ * Capture clock: null captureUt means "no clock" (absent field).
733
+ * Epoch is retained across pushes that omit it (only a present
734
+ * value updates it); warpRate falls back to 1 when absent.
735
+ */
736
+ this._captureUt = msg.content.captureUt ?? null;
737
+ if (msg.content.captureEpoch != null) {
738
+ this._captureEpoch = msg.content.captureEpoch;
739
+ }
740
+ this._warpRate = msg.content.timeWarpRate ?? 1;
741
+ this.emit("settings-change", msg.content);
742
+ break;
743
+ }
744
+ }
745
+ /**
746
+ * Re-source a resurrected camera's handle from the track the client still
747
+ * holds for its slot. Called when {@link KerbcastCameraHandle._setState}
748
+ * reports a Destroyed -> Active transition: the sidecar rebinds the same
749
+ * track without a new slot-map, so nothing else drives the handle back out
750
+ * of the sourceless (static) state it entered on destroy.
751
+ */
752
+ _resourceHandle(flightId) {
753
+ for (const [mid, boundFlightId] of this.flightByMid) {
754
+ if (boundFlightId !== flightId)
755
+ continue;
756
+ const track = this.trackByMid.get(mid);
757
+ if (track) {
758
+ this.getOrCreateHandle(flightId)._setMediaStream(new MediaStream([track]));
759
+ }
760
+ return;
761
+ }
762
+ }
763
+ setState(state) {
764
+ if (this._state === state)
765
+ return;
766
+ this._state = state;
767
+ this.emit("state-change", state);
768
+ }
769
+ /**
770
+ * Inbound RTP video statistics keyed by flight ID. Returns an empty map
771
+ * when not connected or when the transport does not implement `getStats`.
772
+ *
773
+ * Iterates the stats report and matches `inbound-rtp` + `kind === "video"`
774
+ * entries to cameras. Legacy mode matches by `trackIdentifier`; dynamic
775
+ * mode falls back to the `mid` field when `trackIdentifier` is absent
776
+ * (modern browsers include it; some implementations omit it).
777
+ */
778
+ async inboundVideoStats() {
779
+ const result = new Map();
780
+ if (!this.peer?.getStats)
781
+ return result;
782
+ let report;
783
+ try {
784
+ report = await this.peer.getStats();
785
+ }
786
+ catch {
787
+ return result;
788
+ }
789
+ report.forEach((raw) => {
790
+ if (raw.type !== "inbound-rtp" || raw.kind !== "video")
791
+ return;
792
+ const entry = raw;
793
+ // Resolve the flightId: trackIdentifier wins (legacy path populated it),
794
+ // mid fallback covers dynamic mode and browsers that omit trackIdentifier.
795
+ let flightId;
796
+ if (entry.trackIdentifier) {
797
+ flightId = this.flightByTrackId.get(entry.trackIdentifier);
798
+ }
799
+ if (flightId === undefined && entry.mid) {
800
+ flightId = this.flightByMid.get(entry.mid);
801
+ }
802
+ if (flightId === undefined)
803
+ return;
804
+ result.set(flightId, {
805
+ packetsReceived: entry.packetsReceived ?? 0,
806
+ bytesReceived: entry.bytesReceived ?? 0,
807
+ framesReceived: entry.framesReceived,
808
+ framesDecoded: entry.framesDecoded,
809
+ jitter: entry.jitter,
810
+ framesPerSecond: entry.framesPerSecond,
811
+ });
812
+ });
813
+ return result;
814
+ }
815
+ tearDownStreams() {
816
+ this.flightByTrackId.clear();
817
+ for (const handle of this.handles.values()) {
818
+ handle._teardown();
819
+ }
820
+ }
821
+ }