@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.
@@ -0,0 +1,433 @@
1
+ import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload } from "./__generated__/types";
2
+ import { CameraLifecycle } from "./__generated__/types";
3
+ /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
4
+ export interface NoiseConfig {
5
+ /** When false, the pipeline is bypassed and `mediaStream` is the raw WebRTC track. */
6
+ enabled: boolean;
7
+ }
8
+ /**
9
+ * Per-camera handle returned from {@link KerbcastClient.camera}.
10
+ * Stable identity per flight ID for the lifetime of the client.
11
+ */
12
+ export interface KerbcastCameraHandle {
13
+ readonly flightId: number;
14
+ readonly state: CameraState | null;
15
+ readonly mediaStream: MediaStream | null;
16
+ /**
17
+ * True while the camera has a live source whose frames have stopped
18
+ * arriving (the noise pipeline's stall detector). Always false when the
19
+ * noise pipeline is unavailable (no `captureStream`) or bypassed.
20
+ * Transitions are emitted as `stall` events.
21
+ */
22
+ readonly stalled: boolean;
23
+ /** Current {@link setShowStatic} setting. */
24
+ readonly showStatic: boolean;
25
+ setLayers(layers: Layer[]): Promise<void>;
26
+ setRenderSize(width: number, height: number): Promise<void>;
27
+ setFov(fov: number): Promise<void>;
28
+ setPan(yaw: number, pitch: number): Promise<void>;
29
+ /**
30
+ * Set a persistent pan/tilt velocity (normalised, -1..1 per axis;
31
+ * +yaw = right, +pitch = up). Holds until superseded — send
32
+ * `setPanRate(0, 0)` to stop. Smoothing happens in the plugin's frame
33
+ * loop, so call this only when the input changes (e.g. arrow down /
34
+ * release), not on a timer.
35
+ */
36
+ setPanRate(yawRate: number, pitchRate: number): Promise<void>;
37
+ /**
38
+ * Set a persistent zoom velocity (normalised, -1..1; +1 = zoom in,
39
+ * FoV decreasing). Holds until superseded — send `setZoomRate(0)` to
40
+ * stop. Like {@link setPanRate}, call on input change (press-and-hold),
41
+ * not on a timer.
42
+ */
43
+ setZoomRate(rate: number): Promise<void>;
44
+ setDegrade(level: number): Promise<void>;
45
+ /**
46
+ * Request a resolution preset for this camera, or `null` for auto
47
+ * (clear the viewer clamp). Server-wide and last-write-wins across
48
+ * peers; the authoritative result arrives as a `change` event
49
+ * (`state.viewerQuality` + `state.qualityLimitedBy`). The effective
50
+ * resolution is min(operator ceiling, adaptive level, preset): a
51
+ * preset can only lower quality, and the sidecar's adaptive perf
52
+ * machinery keeps overriding it downward while throttled.
53
+ */
54
+ setQuality(preset: QualityPreset | null): Promise<void>;
55
+ requestKeyframe(): Promise<void>;
56
+ /**
57
+ * Override noise settings for this camera only. Takes precedence over the
58
+ * client-level default in {@link KerbcastClientConfig.noise}.
59
+ */
60
+ configure(options: {
61
+ noise?: Partial<NoiseConfig>;
62
+ }): void;
63
+ /**
64
+ * Choose whether animated static is drawn (default true). When false:
65
+ * a stalled source holds its last decoded frame with no noise (the
66
+ * consumer's chrome carries the staleness indicator via the `stall`
67
+ * event / {@link stalled}), and a sourceless path clears to black
68
+ * without noise so only the text overlay renders.
69
+ */
70
+ setShowStatic(enabled: boolean): void;
71
+ on<K extends keyof KerbcastCameraEvents>(event: K, handler: (data: KerbcastCameraEvents[K]) => void): () => void;
72
+ }
73
+ /** Event payloads emitted by a {@link KerbcastCameraHandle}. */
74
+ export interface KerbcastCameraEvents {
75
+ /** Latest `CameraState` from the sidecar's `camera-state-changed`. */
76
+ change: CameraState;
77
+ /** Track arrival or teardown for this camera. `null` on disconnect. */
78
+ stream: MediaStream | null;
79
+ /** Frames stopped arriving on a live source (`true`) / resumed (`false`). */
80
+ stall: boolean;
81
+ }
82
+ /** Configuration for {@link KerbcastClient} construction. */
83
+ export interface KerbcastClientConfig {
84
+ /** Sidecar host (matches the sidecar's `--http-bind`). */
85
+ host: string;
86
+ /** Sidecar port. */
87
+ port: number;
88
+ /**
89
+ * ICE servers to use. Defaults to Google's public STUN server,
90
+ * which is enough for LAN streaming.
91
+ */
92
+ iceServers?: RTCIceServer[];
93
+ /**
94
+ * Default noise settings for all cameras. Individual cameras can override
95
+ * this via `cam.configure({ noise: ... })`. Noise is enabled by default.
96
+ */
97
+ noise?: Partial<NoiseConfig>;
98
+ /**
99
+ * ICE gathering timeout in milliseconds. Passed to the default
100
+ * {@link BrowserKerbcastTransport}; ignored when a custom transport is
101
+ * provided (the transport owns gathering). Defaults to 2000 ms.
102
+ *
103
+ * On LAN topologies where the STUN server is unreachable (e.g. Steam Deck
104
+ * IPv6 LAN with no internet path), gathering can stall for the full STUN
105
+ * timeout. Host candidates alone are sufficient for LAN streaming, so this
106
+ * timeout lets the connect flow proceed once host candidates are gathered.
107
+ */
108
+ iceGatheringTimeoutMs?: number;
109
+ /**
110
+ * Override how the SDP offer/answer is exchanged. Defaults to a POST to the
111
+ * sidecar's HTTP `/offer`. A station screen injects a version that relays
112
+ * the offer through the main screen (which can reach the sidecar), so the
113
+ * station needs no direct sidecar address. The media itself still flows
114
+ * peer-to-sidecar (direct or via TURN) -- only the handshake is brokered.
115
+ */
116
+ negotiate?: (offer: {
117
+ sdp: string;
118
+ cameras: number[];
119
+ slots?: number;
120
+ }) => Promise<{
121
+ sdp: string;
122
+ cameras: number[];
123
+ }>;
124
+ }
125
+ /** WebRTC connection state surface. */
126
+ export type KerbcastConnectionState = "disconnected" | "connecting" | "connected" | "failed";
127
+ /**
128
+ * Inbound RTP video statistics for one camera track, derived from the
129
+ * WebRTC stats report. Fields are `undefined` when the browser has not yet
130
+ * emitted that stat (e.g. `framesPerSecond` before the first full second).
131
+ * `packetsReceived` and `bytesReceived` default to 0 in the same case.
132
+ *
133
+ * Retrieve per-camera stats via {@link KerbcastClient.inboundVideoStats}.
134
+ */
135
+ export interface InboundVideoStats {
136
+ packetsReceived: number;
137
+ bytesReceived: number;
138
+ framesReceived: number | undefined;
139
+ framesDecoded: number | undefined;
140
+ jitter: number | undefined;
141
+ framesPerSecond: number | undefined;
142
+ }
143
+ /**
144
+ * Camera summary returned by {@link KerbcastClient.discover} (`GET /cameras`).
145
+ *
146
+ * This is the shape the sidecar serialises from its internal `CameraInfo`
147
+ * struct. It is a discovery snapshot, not a live operating state: it carries
148
+ * capability and identity fields but not per-connection operator state
149
+ * (`layers`, `renderWidth`, `panYaw`, etc.) -- those arrive over the data
150
+ * channel after `connect()`.
151
+ *
152
+ * Deliberately separate from `CameraState` even though the two share several
153
+ * field names, because the shapes differ: `DiscoveredCamera` has `maxWidth`
154
+ * / `maxHeight` (physical sensor limits) which `CameraState` omits, while
155
+ * `CameraState` adds runtime fields (`layers`, `renderWidth`, `panYaw`, ...).
156
+ */
157
+ export interface DiscoveredCamera {
158
+ flightId: number;
159
+ /** Part-destruction lifecycle. `"active"` for live cameras. */
160
+ lifecycle?: CameraLifecycle;
161
+ partName: string;
162
+ partTitle: string;
163
+ cameraName: string;
164
+ vesselName: string;
165
+ /** Physical sensor width ceiling (pixels). Distinct from `renderWidth` on
166
+ * `CameraState`, which is the currently-negotiated resolution. */
167
+ maxWidth: number;
168
+ /** Physical sensor height ceiling (pixels). */
169
+ maxHeight: number;
170
+ supportsZoom: boolean;
171
+ fov: number;
172
+ fovMin: number;
173
+ fovMax: number;
174
+ supportsPan: boolean;
175
+ panYawMin: number;
176
+ panYawMax: number;
177
+ panPitchMin: number;
178
+ panPitchMax: number;
179
+ /** Current encoder bitrate in bits per second. 0 when no encoder is running. */
180
+ encoderBitrateBps: number;
181
+ /** REMB-derived bandwidth target. 0 until the first feedback arrives. */
182
+ targetBitrateBps: number;
183
+ /** Effective degrade level (max across active subscribers). 0.0 to 1.0. */
184
+ degradeLevel: number;
185
+ }
186
+ /** Event payloads emitted by a {@link KerbcastClient}. */
187
+ export interface KerbcastClientEvents {
188
+ "state-change": KerbcastConnectionState;
189
+ "cameras-change": CameraState[];
190
+ "adaptive-shed": AdaptiveShedPayload;
191
+ error: ErrorPayload;
192
+ /**
193
+ * Fired whenever a `ping` keepalive arrives from the sidecar.
194
+ * Consumers can use this to reset their own staleness watchdogs
195
+ * without intercepting at the transport layer.
196
+ */
197
+ ping: undefined;
198
+ /**
199
+ * Fired when a `settings-state` message arrives (after `Hello` and
200
+ * whenever the plugin-reported throttle state changes). Payload
201
+ * reflects what the plugin has applied, not just what was requested.
202
+ */
203
+ "settings-change": SettingsStatePayload;
204
+ }
205
+ /**
206
+ * Pluggable WebRTC transport. Production code uses the default
207
+ * (browser `RTCPeerConnection`). Tests substitute an in-memory
208
+ * implementation to exercise the state machine without a real peer.
209
+ */
210
+ export interface KerbcastTransport {
211
+ createPeer(iceServers: RTCIceServer[]): KerbcastPeer;
212
+ }
213
+ export interface KerbcastPeer {
214
+ addRecvOnlyTransceiver(): void;
215
+ createDataChannel(label: string): KerbcastDataChannel;
216
+ onTrack(handler: (track: MediaStreamTrack, idx: number, mid: string) => void): void;
217
+ onStateChange(handler: (state: KerbcastConnectionState) => void): void;
218
+ createOffer(): Promise<string>;
219
+ setLocalDescription(sdp: string): Promise<void>;
220
+ setRemoteAnswer(sdp: string): Promise<void>;
221
+ waitForIceComplete(): Promise<void>;
222
+ localSdp(): string;
223
+ close(): void;
224
+ /**
225
+ * Retrieve the raw WebRTC stats report. Optional so existing third-party
226
+ * transports and mocks that predate this method keep compiling without
227
+ * changes. Equivalent to `RTCPeerConnection.getStats(null)`.
228
+ */
229
+ getStats?(): Promise<RTCStatsReport>;
230
+ }
231
+ export interface KerbcastDataChannel {
232
+ send(payload: string): void;
233
+ onOpen(handler: () => void): void;
234
+ onMessage(handler: (raw: string) => void): void;
235
+ onClose(handler: () => void): void;
236
+ }
237
+ /** Options for {@link BrowserKerbcastTransport}. */
238
+ export interface BrowserKerbcastTransportOptions {
239
+ /**
240
+ * Maximum time (ms) to wait for ICE gathering to complete before proceeding
241
+ * with whatever candidates have been gathered. Defaults to 2000 ms.
242
+ *
243
+ * On LAN paths where the STUN server is unreachable, gathering blocks until
244
+ * the STUN timeout elapses. Host candidates are all a local LAN stream
245
+ * needs, so this timeout lets connect proceed without waiting for STUN.
246
+ */
247
+ iceGatheringTimeoutMs?: number;
248
+ }
249
+ export declare class BrowserKerbcastTransport implements KerbcastTransport {
250
+ private readonly opts;
251
+ constructor(opts?: BrowserKerbcastTransportOptions);
252
+ createPeer(iceServers: RTCIceServer[]): KerbcastPeer;
253
+ }
254
+ declare class TypedEmitter<E> {
255
+ private readonly listeners;
256
+ on<K extends keyof E>(event: K, handler: (data: E[K]) => void): () => void;
257
+ protected emit<K extends keyof E>(event: K, data: E[K]): void;
258
+ }
259
+ /**
260
+ * High-level kerbcast sidecar client. Owns the WebRTC peer + the
261
+ * `kerbcast-control` data channel + the per-camera registry + the
262
+ * `MediaStream`s for each subscribed camera.
263
+ *
264
+ * Usage:
265
+ *
266
+ * ```ts
267
+ * const client = new KerbcastClient({ host: "192.168.1.74", port: 8088 });
268
+ * await client.connect();
269
+ *
270
+ * const cam = client.camera(2592004302);
271
+ * await cam.setFov(35);
272
+ * videoEl.srcObject = cam.mediaStream;
273
+ *
274
+ * cam.on("change", (state) => { ... });
275
+ * client.on("adaptive-shed", (event) => { ... });
276
+ * ```
277
+ *
278
+ * Disconnect with {@link disconnect}. The handles returned by
279
+ * `camera()` remain valid across reconnects; their `mediaStream`
280
+ * goes null on disconnect and is replaced when the next connect's
281
+ * tracks arrive.
282
+ */
283
+ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
284
+ private readonly cfg;
285
+ private readonly transport;
286
+ private peer;
287
+ private control;
288
+ private _state;
289
+ private _cameras;
290
+ private readonly handles;
291
+ private requestedOrder;
292
+ /** Set when `connect` is given an explicit slot pool. Switches track
293
+ * routing from legacy index order to mid-keyed dynamic slots. */
294
+ private dynamicMode;
295
+ /** Dynamic mode: transceiver mid -> the live track on that slot. */
296
+ private readonly trackByMid;
297
+ /** Dynamic mode: transceiver mid -> the camera bound to that slot
298
+ * (populated by SlotMap -- initial bindings announced on Hello). */
299
+ private readonly flightByMid;
300
+ /**
301
+ * Legacy mode: raw incoming track id -> flightId. Populated in handleTrack
302
+ * so inboundVideoStats() can match inbound-rtp report entries by
303
+ * trackIdentifier. Cleared on disconnect. Not used in dynamic mode (which
304
+ * matches by mid instead).
305
+ */
306
+ private readonly flightByTrackId;
307
+ /** Sidecar version reported on `Hello`; null before handshake. */
308
+ private _sidecarVersion;
309
+ private _encoderBackend;
310
+ /** Last-received throttle state from `settings-state`. False until the first push. */
311
+ private _throttleMainScreen;
312
+ /**
313
+ * Mission-time capture clock from `settings-state`. `_captureUt` is null
314
+ * until the first clock arrives (and against an old sidecar that never
315
+ * sends one), so consumers treat it as "no clock". `_captureEpoch` bumps
316
+ * on discontinuities (revert / quickload / scene reload); only the change
317
+ * is meaningful. `_warpRate` defaults to 1 when the field is absent.
318
+ */
319
+ private _captureUt;
320
+ private _captureEpoch;
321
+ private _warpRate;
322
+ constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport);
323
+ get state(): KerbcastConnectionState;
324
+ get cameras(): ReadonlyArray<CameraState>;
325
+ get sidecarVersion(): string | null;
326
+ get encoderBackend(): string | null;
327
+ /**
328
+ * Current effective state of the "Throttle KSP main render" setting.
329
+ * Reflects what the plugin has applied (from `settings-state` messages),
330
+ * not just what was last requested. False until the first push arrives.
331
+ */
332
+ get throttleMainScreen(): boolean;
333
+ /**
334
+ * Mission-time capture clock, from the sidecar's ~1Hz `settings-state`.
335
+ * `captureUt` is the KSP universal time (seconds) the current video was
336
+ * captured at, or null when no clock is known (old plugin/sidecar, or
337
+ * before the first push). `epoch` bumps on a UT discontinuity so a
338
+ * consumer can flush and resync. `warpRate` lets a consumer interpolate
339
+ * `captureUt` between samples; defaults to 1.
340
+ */
341
+ get clock(): {
342
+ captureUt: number | null;
343
+ epoch: number;
344
+ warpRate: number;
345
+ };
346
+ /**
347
+ * Send a `set-throttle-main-screen` command to the sidecar, which writes
348
+ * `global.control.json` for the plugin to apply. The plugin persists the
349
+ * change in the per-save difficulty parameter. The resulting
350
+ * `settings-state` broadcast reflects the applied value.
351
+ */
352
+ setThrottleMainScreen(enabled: boolean): Promise<void>;
353
+ /**
354
+ * Pre-handshake camera discovery via the sidecar's HTTP `/cameras`
355
+ * endpoint. Useful when the consumer wants to pick which cameras
356
+ * to subscribe to before opening a peer connection.
357
+ */
358
+ discover(): Promise<DiscoveredCamera[]>;
359
+ /**
360
+ * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
361
+ * `connect` while already connected disconnects first.
362
+ *
363
+ * Two modes:
364
+ * - **Legacy** (no `opts.slots`): one transceiver per requested camera;
365
+ * an empty array subscribes to every currently-known camera. Tracks
366
+ * route by m-line index. Unchanged behaviour.
367
+ * - **Dynamic** (`opts.slots` set): negotiates a pool of `opts.slots`
368
+ * recv-only video slots. `requestedCameras` are the *initial*
369
+ * subscription bound to the first slots; spare slots are filled at
370
+ * runtime via {@link subscribe}. Tracks route by transceiver mid
371
+ * (the sidecar announces bindings via SlotMap), so switching a slot's
372
+ * camera needs no renegotiation.
373
+ */
374
+ connect(requestedCameras?: number[], opts?: {
375
+ slots?: number;
376
+ }): Promise<void>;
377
+ /** Default signaling: POST the offer to the sidecar's HTTP `/offer`. */
378
+ private httpNegotiate;
379
+ disconnect(): void;
380
+ /**
381
+ * Stable per-flight-id handle. Creates a new handle on first call
382
+ * for a given ID; returns the same handle on subsequent calls.
383
+ */
384
+ camera(flightId: number): KerbcastCameraHandle;
385
+ /**
386
+ * Dynamic mode only: bind a camera to a free slot on the already-connected
387
+ * peer. The camera's stream appears on its handle once the sidecar answers
388
+ * with a SlotMap; the sidecar emits an `error` event if no slot is free. No
389
+ * renegotiation.
390
+ */
391
+ subscribe(flightId: number): Promise<void>;
392
+ /**
393
+ * Dynamic mode only: release a camera's slot. Its handle's `mediaStream`
394
+ * goes null once the sidecar confirms with a SlotMap.
395
+ */
396
+ unsubscribe(flightId: number): Promise<void>;
397
+ /**
398
+ * Internal — resolves the effective noise enabled state for a camera,
399
+ * merging the per-camera override (if any) with the client-level default.
400
+ */
401
+ _resolveNoise(override: Partial<NoiseConfig> | null): boolean;
402
+ /**
403
+ * Internal — sends a typed `ClientMessage` over the control
404
+ * channel. Drops silently if the channel hasn't opened yet (and
405
+ * logs to console). Public on the camera handle, but consumers
406
+ * shouldn't need to call this directly.
407
+ */
408
+ _send(msg: ClientMessage): Promise<void>;
409
+ private getOrCreateHandle;
410
+ private handleTrack;
411
+ private handleServerMessage;
412
+ /**
413
+ * Re-source a resurrected camera's handle from the track the client still
414
+ * holds for its slot. Called when {@link KerbcastCameraHandle._setState}
415
+ * reports a Destroyed -> Active transition: the sidecar rebinds the same
416
+ * track without a new slot-map, so nothing else drives the handle back out
417
+ * of the sourceless (static) state it entered on destroy.
418
+ */
419
+ private _resourceHandle;
420
+ private setState;
421
+ /**
422
+ * Inbound RTP video statistics keyed by flight ID. Returns an empty map
423
+ * when not connected or when the transport does not implement `getStats`.
424
+ *
425
+ * Iterates the stats report and matches `inbound-rtp` + `kind === "video"`
426
+ * entries to cameras. Legacy mode matches by `trackIdentifier`; dynamic
427
+ * mode falls back to the `mid` field when `trackIdentifier` is absent
428
+ * (modern browsers include it; some implementations omit it).
429
+ */
430
+ inboundVideoStats(): Promise<Map<number, InboundVideoStats>>;
431
+ private tearDownStreams;
432
+ }
433
+ export {};