@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/CHANGELOG.md +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
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
|
+
}
|