@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
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
|
|
2
|
+
function buildCamera(init) {
|
|
3
|
+
return {
|
|
4
|
+
flightId: init.flightId,
|
|
5
|
+
lifecycle: init.lifecycle ?? CameraLifecycle.Active,
|
|
6
|
+
partName: init.partName ?? `part-${init.flightId}`,
|
|
7
|
+
partTitle: init.partTitle ?? `Part ${init.flightId}`,
|
|
8
|
+
cameraName: init.cameraName ?? `camera-${init.flightId}`,
|
|
9
|
+
vesselName: init.vesselName ?? "Test Vessel",
|
|
10
|
+
layers: init.layers ?? [Layer.Near],
|
|
11
|
+
operatorLayers: init.operatorLayers ?? [Layer.Near],
|
|
12
|
+
renderWidth: init.renderWidth ?? 1280,
|
|
13
|
+
renderHeight: init.renderHeight ?? 720,
|
|
14
|
+
operatorWidth: init.operatorWidth ?? 1280,
|
|
15
|
+
operatorHeight: init.operatorHeight ?? 720,
|
|
16
|
+
supportsZoom: init.supportsZoom ?? true,
|
|
17
|
+
fov: init.fov ?? 60,
|
|
18
|
+
fovMin: init.fovMin ?? 10,
|
|
19
|
+
fovMax: init.fovMax ?? 120,
|
|
20
|
+
supportsPan: init.supportsPan ?? false,
|
|
21
|
+
panYaw: init.panYaw ?? 0,
|
|
22
|
+
panPitch: init.panPitch ?? 0,
|
|
23
|
+
panYawMin: init.panYawMin ?? -90,
|
|
24
|
+
panYawMax: init.panYawMax ?? 90,
|
|
25
|
+
panPitchMin: init.panPitchMin ?? -90,
|
|
26
|
+
panPitchMax: init.panPitchMax ?? 90,
|
|
27
|
+
encoderBitrateBps: init.encoderBitrateBps ?? 0,
|
|
28
|
+
targetBitrateBps: init.targetBitrateBps ?? 0,
|
|
29
|
+
degradeLevel: init.degradeLevel ?? 0,
|
|
30
|
+
viewerQuality: init.viewerQuality,
|
|
31
|
+
qualityLimitedBy: init.qualityLimitedBy,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Fraction of the operator render size each preset targets (mirrors the
|
|
35
|
+
* sidecar's `QualityPreset::scale`). */
|
|
36
|
+
const QUALITY_PRESET_SCALE = {
|
|
37
|
+
[QualityPreset.Full]: 1.0,
|
|
38
|
+
[QualityPreset.ThreeQuarter]: 0.75,
|
|
39
|
+
[QualityPreset.Half]: 0.5,
|
|
40
|
+
[QualityPreset.Quarter]: 0.25,
|
|
41
|
+
};
|
|
42
|
+
/** Scale + floor-to-even like the plugin's QualityClamp.ScaleDimension. */
|
|
43
|
+
function scaleDim(operatorDim, scale) {
|
|
44
|
+
const v = Math.trunc(operatorDim * scale) & ~1;
|
|
45
|
+
return v < 2 ? 2 : v;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* In-process protocol-level fake for the kerbcast sidecar.
|
|
49
|
+
*
|
|
50
|
+
* Owns a camera registry and speaks the full kerbcast wire protocol.
|
|
51
|
+
* Use it in tests to exercise `KerbcastClient` behaviour without a real
|
|
52
|
+
* sidecar or WebRTC stack.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const sidecar = new MockSidecar();
|
|
56
|
+
* sidecar.addCamera({ flightId: 42 });
|
|
57
|
+
*
|
|
58
|
+
* vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
59
|
+
* Promise.resolve(MockSidecar.makeOfferResponse([42]))
|
|
60
|
+
* );
|
|
61
|
+
*
|
|
62
|
+
* const client = new KerbcastClient({ host: "localhost", port: 8088 }, sidecar.createTransport());
|
|
63
|
+
* await client.connect([42]);
|
|
64
|
+
* sidecar.open(); // fires hello + camera-snapshot
|
|
65
|
+
*
|
|
66
|
+
* expect(client.cameras[0].flightId).toBe(42);
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export class MockSidecar {
|
|
70
|
+
constructor() {
|
|
71
|
+
this._cameras = new Map();
|
|
72
|
+
this._commands = [];
|
|
73
|
+
this._throttleMainScreen = false;
|
|
74
|
+
this._trackIdx = 0;
|
|
75
|
+
/** Slot mids available for the dynamic-subscription model. Override with
|
|
76
|
+
* {@link withSlots} before connecting if a test needs a specific pool. */
|
|
77
|
+
this._slotMids = ["0", "1", "2", "3"];
|
|
78
|
+
/** mid -> camera currently bound to that slot. */
|
|
79
|
+
this._slotBindings = new Map();
|
|
80
|
+
/**
|
|
81
|
+
* Per-flight inbound stats to return from the fake getStats. Set via
|
|
82
|
+
* {@link setInboundStats}. Keyed by flightId.
|
|
83
|
+
*/
|
|
84
|
+
this._inboundStats = new Map();
|
|
85
|
+
/**
|
|
86
|
+
* Tracks delivered by {@link deliverTrack}, keyed by mid/idx string so
|
|
87
|
+
* getStats can synthesize a trackIdentifier matching what the client saw.
|
|
88
|
+
*/
|
|
89
|
+
this._deliveredTracks = new Map();
|
|
90
|
+
}
|
|
91
|
+
/** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
|
|
92
|
+
addCamera(init) {
|
|
93
|
+
this._cameras.set(init.flightId, buildCamera(init));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Returns a `KerbcastTransport` backed by this mock. Pass it as the
|
|
97
|
+
* second argument to `KerbcastClient`.
|
|
98
|
+
*/
|
|
99
|
+
createTransport() {
|
|
100
|
+
const self = this;
|
|
101
|
+
return {
|
|
102
|
+
createPeer() {
|
|
103
|
+
const channel = {
|
|
104
|
+
send(payload) {
|
|
105
|
+
const msg = JSON.parse(payload);
|
|
106
|
+
self._commands.push(msg);
|
|
107
|
+
self._handleClientMessage(msg);
|
|
108
|
+
},
|
|
109
|
+
onOpen(h) {
|
|
110
|
+
self._openHandler = h;
|
|
111
|
+
},
|
|
112
|
+
onMessage(h) {
|
|
113
|
+
self._clientMsgHandler = h;
|
|
114
|
+
},
|
|
115
|
+
onClose() { },
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
addRecvOnlyTransceiver() { },
|
|
119
|
+
createDataChannel: () => channel,
|
|
120
|
+
onTrack(h) {
|
|
121
|
+
self._onTrackHandler = h;
|
|
122
|
+
},
|
|
123
|
+
onStateChange(h) {
|
|
124
|
+
self._stateHandler = h;
|
|
125
|
+
},
|
|
126
|
+
createOffer: async () => "v=0\r\n",
|
|
127
|
+
setLocalDescription: async () => { },
|
|
128
|
+
setRemoteAnswer: async () => { },
|
|
129
|
+
waitForIceComplete: async () => { },
|
|
130
|
+
localSdp: () => "v=0\r\n",
|
|
131
|
+
close() { },
|
|
132
|
+
getStats: async () => self._buildStatsReport(),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Simulate the sidecar completing the WebRTC handshake. Fires the
|
|
139
|
+
* channel `onOpen` handler (which triggers the client's `hello`), then
|
|
140
|
+
* responds with `hello` + `camera-snapshot`.
|
|
141
|
+
*/
|
|
142
|
+
open() {
|
|
143
|
+
this._openHandler?.();
|
|
144
|
+
this._sendToClient({ type: "hello", content: { sidecarVersion: "0.0.1-mock", encoderBackend: "mock" } });
|
|
145
|
+
this._sendToClient({ type: "camera-snapshot", content: { cameras: Array.from(this._cameras.values()) } });
|
|
146
|
+
this._sendToClient({ type: "settings-state", content: { throttleMainScreen: this._throttleMainScreen } });
|
|
147
|
+
}
|
|
148
|
+
/** Drive the underlying peer's connection-state handler. */
|
|
149
|
+
setConnectionState(state) {
|
|
150
|
+
this._stateHandler?.(state);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Mark a camera as destroyed and push a `camera-state-changed` message
|
|
154
|
+
* to the client. The camera stays in the internal registry with
|
|
155
|
+
* `lifecycle: Destroyed`.
|
|
156
|
+
*/
|
|
157
|
+
destroyCamera(flightId) {
|
|
158
|
+
const cam = this._cameras.get(flightId);
|
|
159
|
+
if (!cam)
|
|
160
|
+
return;
|
|
161
|
+
const destroyed = { ...cam, lifecycle: CameraLifecycle.Destroyed };
|
|
162
|
+
this._cameras.set(flightId, destroyed);
|
|
163
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: destroyed } });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Apply a partial update to an existing camera and push a
|
|
167
|
+
* `camera-state-changed` message to the client.
|
|
168
|
+
*/
|
|
169
|
+
updateCamera(flightId, partial) {
|
|
170
|
+
const cam = this._cameras.get(flightId);
|
|
171
|
+
if (!cam)
|
|
172
|
+
return;
|
|
173
|
+
const updated = { ...cam, ...partial };
|
|
174
|
+
this._cameras.set(flightId, updated);
|
|
175
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Replace the entire camera registry and push a fresh `camera-snapshot` to
|
|
179
|
+
* the client. Models a vessel change / scene switch where the set of
|
|
180
|
+
* available cameras changes (cameras appear or disappear) — distinct from
|
|
181
|
+
* {@link destroyCamera}, which keeps the camera present but `Destroyed`.
|
|
182
|
+
*/
|
|
183
|
+
setCameras(inits) {
|
|
184
|
+
this._cameras.clear();
|
|
185
|
+
for (const init of inits) {
|
|
186
|
+
this._cameras.set(init.flightId, buildCamera(init));
|
|
187
|
+
}
|
|
188
|
+
this._sendToClient({
|
|
189
|
+
type: "camera-snapshot",
|
|
190
|
+
content: { cameras: Array.from(this._cameras.values()) },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Send a `ping` from the sidecar; the client should respond with `pong`. */
|
|
194
|
+
firePing() {
|
|
195
|
+
this._sendToClient({ type: "ping" });
|
|
196
|
+
}
|
|
197
|
+
/** Push an `adaptive-shed` event to the client. */
|
|
198
|
+
fireAdaptiveShed(payload) {
|
|
199
|
+
this._sendToClient({ type: "adaptive-shed", content: payload });
|
|
200
|
+
}
|
|
201
|
+
/** Push an `error` event to the client (simulates a sidecar error reply). */
|
|
202
|
+
fireError(payload) {
|
|
203
|
+
this._sendToClient({ type: "error", content: payload });
|
|
204
|
+
}
|
|
205
|
+
/** Current mock-sidecar throttle state (reflects `set-throttle-main-screen` commands). */
|
|
206
|
+
get throttleMainScreen() {
|
|
207
|
+
return this._throttleMainScreen;
|
|
208
|
+
}
|
|
209
|
+
/** Push a `settings-state` event to the client (simulates a plugin-status-change broadcast). */
|
|
210
|
+
fireSettingsState(payload) {
|
|
211
|
+
this._throttleMainScreen = payload.throttleMainScreen;
|
|
212
|
+
this._sendToClient({ type: "settings-state", content: payload });
|
|
213
|
+
}
|
|
214
|
+
/** Configure the slot-pool mids before connecting (dynamic mode). */
|
|
215
|
+
withSlots(mids) {
|
|
216
|
+
this._slotMids = [...mids];
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Deliver a track onto a slot (by mid), simulating the slot's media
|
|
221
|
+
* arriving over WebRTC. The client routes it to whichever camera is bound
|
|
222
|
+
* to that mid. (jsdom can't make real tracks; pass a stub in unit tests or
|
|
223
|
+
* a `canvas.captureStream()` track in a real-browser harness.)
|
|
224
|
+
*
|
|
225
|
+
* The track is remembered so the fake `getStats()` can synthesize a
|
|
226
|
+
* `trackIdentifier` matching what the client received.
|
|
227
|
+
*/
|
|
228
|
+
deliverTrack(mid, track) {
|
|
229
|
+
this._deliveredTracks.set(mid, track);
|
|
230
|
+
this._onTrackHandler?.(track, this._trackIdx++, mid);
|
|
231
|
+
}
|
|
232
|
+
/** The slot mid currently carrying `flightId`, or undefined. */
|
|
233
|
+
slotMidFor(flightId) {
|
|
234
|
+
for (const [mid, fid] of this._slotBindings) {
|
|
235
|
+
if (fid === flightId)
|
|
236
|
+
return mid;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
/** Every `ClientMessage` received from the client, in order. */
|
|
241
|
+
get commands() {
|
|
242
|
+
return this._commands;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Find the most recent client command of the given type. Pass `flightId`
|
|
246
|
+
* to further filter by camera (ignored for message types without a
|
|
247
|
+
* `content.flightId` field).
|
|
248
|
+
*/
|
|
249
|
+
lastCommand(type, flightId) {
|
|
250
|
+
for (let i = this._commands.length - 1; i >= 0; i--) {
|
|
251
|
+
const cmd = this._commands[i];
|
|
252
|
+
if (cmd.type !== type)
|
|
253
|
+
continue;
|
|
254
|
+
if (flightId !== undefined) {
|
|
255
|
+
const c = cmd;
|
|
256
|
+
if (c.content?.flightId !== flightId)
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
return cmd;
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Build a `Response` that looks like the sidecar's `POST /offer` reply.
|
|
265
|
+
* Pass to `vi.spyOn(globalThis, "fetch").mockImplementation(...)` so the
|
|
266
|
+
* client's handshake can complete without a real HTTP server.
|
|
267
|
+
*
|
|
268
|
+
* ```ts
|
|
269
|
+
* vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
|
270
|
+
* Promise.resolve(MockSidecar.makeOfferResponse([42]))
|
|
271
|
+
* );
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
static makeOfferResponse(cameras) {
|
|
275
|
+
return new Response(JSON.stringify({ sdp: "v=0\r\n", cameras }), { status: 200, headers: { "Content-Type": "application/json" } });
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Signaling-seam analogue of {@link makeOfferResponse}: resolve an offer's
|
|
279
|
+
* answer without HTTP. Pass as the client's `negotiate` config to exercise
|
|
280
|
+
* the brokered-signaling path a station uses.
|
|
281
|
+
*/
|
|
282
|
+
negotiate(offer) {
|
|
283
|
+
return Promise.resolve({ sdp: "v=0\r\n", cameras: offer.cameras });
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Configure the inbound video stats the fake `getStats()` will return for
|
|
287
|
+
* a given camera. Call before `client.inboundVideoStats()` in tests.
|
|
288
|
+
*
|
|
289
|
+
* The mock synthesizes a minimal `RTCStatsReport`-shaped object: one entry
|
|
290
|
+
* per flight that has stats set, with `type: "inbound-rtp"`, `kind: "video"`,
|
|
291
|
+
* and either a `trackIdentifier` matching the track delivered for that camera
|
|
292
|
+
* (legacy path) or a `mid` matching the slot binding (dynamic path), plus
|
|
293
|
+
* the stat fields from `partialStats`.
|
|
294
|
+
*
|
|
295
|
+
* ```ts
|
|
296
|
+
* sidecar.setInboundStats(42, { packetsReceived: 1000, framesDecoded: 300 });
|
|
297
|
+
* const stats = await client.inboundVideoStats();
|
|
298
|
+
* expect(stats.get(42)?.packetsReceived).toBe(1000);
|
|
299
|
+
* ```
|
|
300
|
+
*/
|
|
301
|
+
setInboundStats(flightId, partialStats) {
|
|
302
|
+
this._inboundStats.set(flightId, partialStats);
|
|
303
|
+
}
|
|
304
|
+
/** Build a minimal RTCStatsReport-compatible object for the current state. */
|
|
305
|
+
_buildStatsReport() {
|
|
306
|
+
const entries = [];
|
|
307
|
+
for (const [flightId, stats] of this._inboundStats) {
|
|
308
|
+
const id = `inbound-rtp-${flightId}`;
|
|
309
|
+
// Resolve the identifier: prefer trackIdentifier from the delivered track
|
|
310
|
+
// (legacy path); fall back to mid from the slot binding (dynamic path).
|
|
311
|
+
let trackIdentifier;
|
|
312
|
+
let mid;
|
|
313
|
+
// Check slot bindings (dynamic mode).
|
|
314
|
+
for (const [slotMid, fid] of this._slotBindings) {
|
|
315
|
+
if (fid === flightId) {
|
|
316
|
+
mid = slotMid;
|
|
317
|
+
const track = this._deliveredTracks.get(slotMid);
|
|
318
|
+
if (track?.id)
|
|
319
|
+
trackIdentifier = track.id;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Legacy mode: look for a delivered track whose mid matches the index
|
|
324
|
+
// position (mids in legacy mode are the slot index strings "0", "1", ...).
|
|
325
|
+
if (!trackIdentifier && !mid) {
|
|
326
|
+
for (const [deliveredMid, track] of this._deliveredTracks) {
|
|
327
|
+
if (track.id) {
|
|
328
|
+
trackIdentifier = track.id;
|
|
329
|
+
mid = deliveredMid;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const entry = {
|
|
335
|
+
id,
|
|
336
|
+
type: "inbound-rtp",
|
|
337
|
+
timestamp: Date.now(),
|
|
338
|
+
kind: "video",
|
|
339
|
+
trackIdentifier,
|
|
340
|
+
mid,
|
|
341
|
+
packetsReceived: stats.packetsReceived ?? 0,
|
|
342
|
+
bytesReceived: stats.bytesReceived ?? 0,
|
|
343
|
+
framesReceived: stats.framesReceived,
|
|
344
|
+
framesDecoded: stats.framesDecoded,
|
|
345
|
+
jitter: stats.jitter,
|
|
346
|
+
framesPerSecond: stats.framesPerSecond,
|
|
347
|
+
};
|
|
348
|
+
entries.push([id, entry]);
|
|
349
|
+
}
|
|
350
|
+
// Build a Map that satisfies the RTCStatsReport interface (iterable + forEach).
|
|
351
|
+
const map = new Map(entries);
|
|
352
|
+
return map;
|
|
353
|
+
}
|
|
354
|
+
_sendToClient(msg) {
|
|
355
|
+
this._clientMsgHandler?.(JSON.stringify(msg));
|
|
356
|
+
}
|
|
357
|
+
_handleClientMessage(msg) {
|
|
358
|
+
switch (msg.type) {
|
|
359
|
+
case "set-fov": {
|
|
360
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
361
|
+
if (cam)
|
|
362
|
+
this._cameras.set(msg.content.flightId, { ...cam, fov: msg.content.fov });
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case "set-layers": {
|
|
366
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
367
|
+
if (cam) {
|
|
368
|
+
this._cameras.set(msg.content.flightId, {
|
|
369
|
+
...cam,
|
|
370
|
+
layers: msg.content.layers,
|
|
371
|
+
operatorLayers: msg.content.layers,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "set-render-size": {
|
|
377
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
378
|
+
if (cam) {
|
|
379
|
+
this._cameras.set(msg.content.flightId, {
|
|
380
|
+
...cam,
|
|
381
|
+
renderWidth: msg.content.width,
|
|
382
|
+
renderHeight: msg.content.height,
|
|
383
|
+
operatorWidth: msg.content.width,
|
|
384
|
+
operatorHeight: msg.content.height,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
case "set-pan": {
|
|
390
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
391
|
+
if (cam) {
|
|
392
|
+
this._cameras.set(msg.content.flightId, {
|
|
393
|
+
...cam,
|
|
394
|
+
panYaw: msg.content.yaw,
|
|
395
|
+
panPitch: msg.content.pitch,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case "set-degrade": {
|
|
401
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
402
|
+
if (cam) {
|
|
403
|
+
this._cameras.set(msg.content.flightId, { ...cam, degradeLevel: msg.content.level });
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case "set-quality": {
|
|
408
|
+
// Models the request being honored (no adaptive throttle in the
|
|
409
|
+
// mock): effective dims become the preset's fraction of the
|
|
410
|
+
// operator ceiling, and the authoritative state is echoed back as
|
|
411
|
+
// camera-state-changed, the same broadcast the sidecar fans out
|
|
412
|
+
// to every peer. Use `updateCamera` to simulate the throttled case
|
|
413
|
+
// (renderWidth below the target + qualityLimitedBy: "throttled").
|
|
414
|
+
const cam = this._cameras.get(msg.content.flightId);
|
|
415
|
+
if (!cam) {
|
|
416
|
+
this._sendToClient({
|
|
417
|
+
type: "error",
|
|
418
|
+
content: {
|
|
419
|
+
message: `no camera with flight_id=${msg.content.flightId}`,
|
|
420
|
+
source: ErrorSource.Sidecar,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
const preset = msg.content.preset ?? undefined;
|
|
426
|
+
const scale = preset ? QUALITY_PRESET_SCALE[preset] : 1.0;
|
|
427
|
+
const updated = {
|
|
428
|
+
...cam,
|
|
429
|
+
viewerQuality: preset,
|
|
430
|
+
qualityLimitedBy: undefined,
|
|
431
|
+
renderWidth: scaleDim(cam.operatorWidth, scale),
|
|
432
|
+
renderHeight: scaleDim(cam.operatorHeight, scale),
|
|
433
|
+
};
|
|
434
|
+
this._cameras.set(msg.content.flightId, updated);
|
|
435
|
+
this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case "subscribe": {
|
|
439
|
+
const flightId = msg.content.flightId;
|
|
440
|
+
const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
|
|
441
|
+
if (freeMid === undefined) {
|
|
442
|
+
this._sendToClient({
|
|
443
|
+
type: "error",
|
|
444
|
+
content: { message: "no free slot", source: ErrorSource.Sidecar },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
this._slotBindings.set(freeMid, flightId);
|
|
449
|
+
this._sendToClient({
|
|
450
|
+
type: "slot-map",
|
|
451
|
+
content: { mid: freeMid, flightId },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
case "unsubscribe": {
|
|
457
|
+
const flightId = msg.content.flightId;
|
|
458
|
+
let bound;
|
|
459
|
+
for (const [mid, fid] of this._slotBindings) {
|
|
460
|
+
if (fid === flightId) {
|
|
461
|
+
bound = mid;
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (bound !== undefined) {
|
|
466
|
+
this._slotBindings.delete(bound);
|
|
467
|
+
this._sendToClient({
|
|
468
|
+
type: "slot-map",
|
|
469
|
+
content: { mid: bound, flightId: undefined },
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
// Persistent velocities are integrated frame-by-frame by the plugin;
|
|
475
|
+
// the mock has no frame clock, so it doesn't model their *effect* on
|
|
476
|
+
// panYaw/fov. They're still recorded in `_commands`, so consumer tests
|
|
477
|
+
// can assert the command was sent via `lastCommand("set-pan-rate")`.
|
|
478
|
+
case "set-pan-rate":
|
|
479
|
+
case "set-zoom-rate":
|
|
480
|
+
case "hello":
|
|
481
|
+
case "pong":
|
|
482
|
+
case "request-keyframe":
|
|
483
|
+
break;
|
|
484
|
+
case "set-throttle-main-screen": {
|
|
485
|
+
/* Flip state and echo SettingsState back, mirroring the sidecar's broadcast. */
|
|
486
|
+
this._throttleMainScreen = msg.content.enabled;
|
|
487
|
+
this._sendToClient({
|
|
488
|
+
type: "settings-state",
|
|
489
|
+
content: { throttleMainScreen: this._throttleMainScreen },
|
|
490
|
+
});
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
case "disconnect":
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Install jsdom shims needed by kerbcast component tests.
|
|
500
|
+
*
|
|
501
|
+
* jsdom omits several browser APIs that the SDK and React components call at
|
|
502
|
+
* construction or mount time. Stubbing here keeps individual tests clean.
|
|
503
|
+
* Each shim is idempotent so setup files can call `installDomStubs()`
|
|
504
|
+
* unconditionally.
|
|
505
|
+
*
|
|
506
|
+
* Stubs installed:
|
|
507
|
+
* ResizeObserver - jsdom does not implement it; CameraFeed uses it to
|
|
508
|
+
* drive auto render-size updates.
|
|
509
|
+
* captureStream - jsdom's HTMLCanvasElement lacks captureStream; the
|
|
510
|
+
* noise pipeline calls it to get its output MediaStream.
|
|
511
|
+
* Returns a stub MediaStream so callers receive a valid
|
|
512
|
+
* object rather than throwing.
|
|
513
|
+
* MediaStream - jsdom's MediaStream constructor is incomplete; tests
|
|
514
|
+
* need to construct instances for track delivery and
|
|
515
|
+
* stream assertions.
|
|
516
|
+
* play - jsdom prints "Not implemented" for HTMLMediaElement.play;
|
|
517
|
+
* the noise pipeline awaits it on the internal video element.
|
|
518
|
+
* matchMedia - jsdom does not implement window.matchMedia; theme
|
|
519
|
+
* detection reads prefers-color-scheme through it.
|
|
520
|
+
*/
|
|
521
|
+
export function installDomStubs() {
|
|
522
|
+
// ResizeObserver: jsdom omits entirely; CameraFeed's auto-size hook needs it.
|
|
523
|
+
if (typeof globalThis.ResizeObserver === "undefined") {
|
|
524
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
525
|
+
observe() { }
|
|
526
|
+
unobserve() { }
|
|
527
|
+
disconnect() { }
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// captureStream: jsdom's HTMLCanvasElement does not have it; the noise
|
|
531
|
+
// pipeline's tryCreateNoisePipeline checks for it and returns null when
|
|
532
|
+
// absent (safe degrade), but component tests that exercise the stream
|
|
533
|
+
// path directly need a stub that returns a constructible MediaStream.
|
|
534
|
+
if (typeof HTMLCanvasElement !== "undefined" &&
|
|
535
|
+
typeof HTMLCanvasElement.prototype.captureStream !== "function") {
|
|
536
|
+
HTMLCanvasElement.prototype.captureStream =
|
|
537
|
+
(_fps) => new StubMediaStream();
|
|
538
|
+
}
|
|
539
|
+
// MediaStream: jsdom's implementation is minimal and not always constructible
|
|
540
|
+
// with tracks. Provide a stub that satisfies the basic interface used by
|
|
541
|
+
// tests (getTracks, addTrack, id).
|
|
542
|
+
installStubMediaStream();
|
|
543
|
+
// play: jsdom's HTMLMediaElement.play is not implemented and prints a warning.
|
|
544
|
+
// The noise pipeline awaits video.play() on its internal video element.
|
|
545
|
+
if (typeof HTMLMediaElement !== "undefined") {
|
|
546
|
+
HTMLMediaElement.prototype.play = () => Promise.resolve();
|
|
547
|
+
}
|
|
548
|
+
// matchMedia: jsdom does not implement window.matchMedia; theme detection
|
|
549
|
+
// queries prefers-color-scheme through it. Stub returns a non-matching
|
|
550
|
+
// MediaQueryList so tests default to the light theme unless overridden.
|
|
551
|
+
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
|
552
|
+
window.matchMedia = (query) => ({
|
|
553
|
+
matches: false,
|
|
554
|
+
media: query,
|
|
555
|
+
onchange: null,
|
|
556
|
+
addListener() { },
|
|
557
|
+
removeListener() { },
|
|
558
|
+
addEventListener() { },
|
|
559
|
+
removeEventListener() { },
|
|
560
|
+
dispatchEvent: () => false,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/** Minimal MediaStream stand-in for jsdom environments. */
|
|
565
|
+
class StubMediaStream {
|
|
566
|
+
constructor() {
|
|
567
|
+
this.id = Math.random().toString(36).slice(2);
|
|
568
|
+
this._tracks = [];
|
|
569
|
+
}
|
|
570
|
+
getTracks() {
|
|
571
|
+
return [...this._tracks];
|
|
572
|
+
}
|
|
573
|
+
getVideoTracks() {
|
|
574
|
+
return this._tracks.filter((t) => t.kind === "video");
|
|
575
|
+
}
|
|
576
|
+
getAudioTracks() {
|
|
577
|
+
return this._tracks.filter((t) => t.kind === "audio");
|
|
578
|
+
}
|
|
579
|
+
addTrack(track) {
|
|
580
|
+
this._tracks.push(track);
|
|
581
|
+
}
|
|
582
|
+
removeTrack(track) {
|
|
583
|
+
const i = this._tracks.indexOf(track);
|
|
584
|
+
if (i !== -1)
|
|
585
|
+
this._tracks.splice(i, 1);
|
|
586
|
+
}
|
|
587
|
+
clone() {
|
|
588
|
+
return new StubMediaStream();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function installStubMediaStream() {
|
|
592
|
+
if (typeof globalThis === "undefined")
|
|
593
|
+
return;
|
|
594
|
+
// Only replace if the native MediaStream is not constructible (jsdom
|
|
595
|
+
// registers the class but its constructor requires active getUserMedia
|
|
596
|
+
// permissions that are never granted in a test environment).
|
|
597
|
+
try {
|
|
598
|
+
const ms = new globalThis.MediaStream();
|
|
599
|
+
if (typeof ms.getTracks === "function")
|
|
600
|
+
return; // native is adequate
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// Fall through to install the stub.
|
|
604
|
+
}
|
|
605
|
+
globalThis.MediaStream = StubMediaStream;
|
|
606
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ksp-gonogo/kerbcast",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
|
|
5
|
+
"license": "CC-BY-NC-SA-4.0",
|
|
6
|
+
"author": "Jon Pepler",
|
|
7
|
+
"homepage": "https://github.com/ksp-gonogo/kerbcast#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/ksp-gonogo/kerbcast/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/ksp-gonogo/kerbcast.git",
|
|
14
|
+
"directory": "client-sdk/typescript"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"kerbcast",
|
|
18
|
+
"ksp",
|
|
19
|
+
"kerbal-space-program",
|
|
20
|
+
"webrtc",
|
|
21
|
+
"h264",
|
|
22
|
+
"protocol",
|
|
23
|
+
"typeshare"
|
|
24
|
+
],
|
|
25
|
+
"main": "dist/index.js",
|
|
26
|
+
"types": "dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./testing": {
|
|
33
|
+
"types": "./dist/testing/index.d.ts",
|
|
34
|
+
"default": "./dist/testing/index.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/",
|
|
39
|
+
"src/",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"jsdom": "^29.0.2",
|
|
48
|
+
"typescript": "^5.4.0",
|
|
49
|
+
"vitest": "^4.1.4"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"regen": "typeshare ../../sidecar/src --lang=typescript --output-file=src/__generated__/types.ts"
|
|
56
|
+
}
|
|
57
|
+
}
|