@ksp-gonogo/kerbcast 1.11.0-rc.4 → 1.11.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/README.md CHANGED
@@ -63,6 +63,11 @@ Commands can be sent as soon as `connect` resolves. Any sent before the
63
63
  control channel has opened wait for it, and reject if it has not opened
64
64
  within 10 s or the client disconnects first.
65
65
 
66
+ Kerbcast streams H.264 only. A browser that offers none (some Firefox builds
67
+ ship without OpenH264) is refused, and `connect` rejects with an
68
+ `OfferRefusedError` whose `code` is `ErrorCode.CodecUnsupported` and whose
69
+ message says what to change.
70
+
66
71
  ### Pre-handshake discovery
67
72
 
68
73
  ```ts
@@ -89,6 +94,65 @@ const setLayers: ClientMessage = {
89
94
  controlChannel.send(JSON.stringify(setLayers));
90
95
  ```
91
96
 
97
+ ### Encoded-frame transforms
98
+
99
+ `cam.setEncodedTransform(factory)` runs your transform on a camera's encoded
100
+ frames before the browser decodes them. It always runs before Kerbcast's
101
+ static and frame lock, so anything it does (holding frames back, dropping
102
+ them) shows up in `cam.mediaStream` with the static still drawn on top.
103
+
104
+ Kerbcast installs the transform on whichever receiver carries the camera. A
105
+ transform instance works on one receiver only, so you pass a factory:
106
+ Kerbcast calls it for a fresh instance when the camera is bound, again when a
107
+ slot rebind moves the camera to another receiver, and again after a
108
+ reconnect. A receiver the camera leaves goes back to passthrough, and frames
109
+ the old instance still holds are dropped. `setEncodedTransform(null)`
110
+ detaches. It works in both legacy and dynamic slot modes.
111
+
112
+ It needs `encodedTransforms: true` when the client is constructed, and
113
+ `setEncodedTransform` throws without it. The option gives every receiver a
114
+ passthrough transform from the start, because Chrome only honours a receiver
115
+ transform that was in place before negotiation. It costs a worker hop per
116
+ frame for every camera, so leave it off unless you use this. The passthrough
117
+ worker loads from a `blob:` URL, so a Content Security Policy must allow
118
+ `worker-src blob:`. `setEncodedTransform` also throws when the browser has no
119
+ install path; `client.encodedTransformPath` reports which one you will get:
120
+
121
+ - `"script"`: return an `RTCRtpScriptTransform` (Chrome, Safari and Firefox)
122
+ - `"streams"`: return a `TransformStream` of `RTCEncodedVideoFrame`, run on the main thread over `createEncodedStreams()` (older Chrome)
123
+
124
+ A one-second delay on the `"script"` path:
125
+
126
+ ```ts
127
+ const client = new KerbcastClient({ host, port, encodedTransforms: true });
128
+ const worker = new Worker(new URL("./delay-worker.js", import.meta.url));
129
+
130
+ client.camera(2592004302).setEncodedTransform(({ flightId, path }) =>
131
+ path === "script"
132
+ ? new RTCRtpScriptTransform(worker, { flightId, delayMs: 1000 })
133
+ : delayStream(1000),
134
+ );
135
+
136
+ // delay-worker.js
137
+ onrtctransform = ({ transformer }) => {
138
+ const { delayMs } = transformer.options;
139
+ transformer.readable
140
+ .pipeThrough(
141
+ new TransformStream({
142
+ transform(frame, controller) {
143
+ setTimeout(() => controller.enqueue(frame), delayMs);
144
+ },
145
+ }),
146
+ )
147
+ .pipeTo(transformer.writable)
148
+ .catch(() => {}); // a replaced instance is aborted
149
+ };
150
+ ```
151
+
152
+ `delayStream` is the same `TransformStream` built on the main thread. A
153
+ factory that throws, or returns the wrong kind for the path, is reported as a
154
+ client `error` event carrying the camera's `flightId`.
155
+
92
156
  ## Per-camera capabilities
93
157
 
94
158
  `CameraState` includes capability flags so consumers render only the
@@ -291,6 +355,14 @@ await client.setCommandAim(hill.flightId, null);
291
355
  A station watching a craft is the same with
292
356
  `{ kind: CommandVantageKind.Station, station: "KSC" }` and a `vessel` aim.
293
357
 
358
+ A `vessel` aim, or an `orbit` or `positionAt` aim's `subjectPersistentId`,
359
+ must name a craft in the camera's `commandCentre.targets`. One that is not
360
+ there (a craft merged into another by docking, or destroyed) makes
361
+ `setCommandAim` reject with a `CommandTargetUnknownError`; a call that races
362
+ the list changing comes back as an `error` event with code
363
+ `ErrorCode.CommandTargetUnknown`. Before the game has reported the camera's
364
+ list, the aim is sent and the game decides.
365
+
294
366
  ### The command lock
295
367
 
296
368
  A mod in game can take a lock on every command camera's aim
@@ -362,6 +362,15 @@ export interface CameraState {
362
362
  */
363
363
  operatorWidth: number;
364
364
  operatorHeight: number;
365
+ /**
366
+ * Configured capture size: the most this camera renders at (settings.cfg
367
+ * `MaxQuality` or `Width`/`Height`, or a kerbal face camera's square
368
+ * ring). Its aspect is the feed's shape: auto-resolution picks a size at
369
+ * this aspect and never follows the viewer's box. Absent from a sidecar
370
+ * that predates the field.
371
+ */
372
+ maxWidth?: number;
373
+ maxHeight?: number;
365
374
  /**
366
375
  * Whether the part's Hullcam module supports runtime FoV changes
367
376
  * (i.e. it's a `MuMechModuleHullCameraZoom`, not the fixed base
@@ -623,7 +632,18 @@ export declare enum ErrorCode {
623
632
  /** The named camera exists but its part is gone. */
624
633
  CameraDestroyed = "camera-destroyed",
625
634
  /** A command authority in game holds the command cameras' aim lock. */
626
- CommandLocked = "command-locked"
635
+ CommandLocked = "command-locked",
636
+ /**
637
+ * A command aim names a craft that is not in the camera's latest
638
+ * `commandCentre.targets`, for example one merged away in a dock.
639
+ */
640
+ CommandTargetUnknown = "command-target-unknown",
641
+ /**
642
+ * `POST /offer` refused: the offer's video carries no H.264 with
643
+ * packetization-mode=1, the only video Kerbcast sends. A browser
644
+ * without H.264 support (some Firefox builds lack OpenH264) offers none.
645
+ */
646
+ CodecUnsupported = "codec-unsupported"
627
647
  }
628
648
  export interface ErrorPayload {
629
649
  message: string;
@@ -664,6 +684,11 @@ export interface FrameStampPayload {
664
684
  export interface HelloPayload {
665
685
  sidecarVersion: string;
666
686
  encoderBackend: string;
687
+ /**
688
+ * True when this sidecar sends `FrameStamp` to a client that asks with
689
+ * `SetFrameStamps`. Absent from sidecars older than 1.11.
690
+ */
691
+ frameStamps?: boolean;
667
692
  }
668
693
  /**
669
694
  * A consumer reporting its OWN current display size in px for one camera.
@@ -735,6 +760,9 @@ export interface SetFovPayload {
735
760
  flightId: number;
736
761
  fov: number;
737
762
  }
763
+ export interface SetFrameStampsPayload {
764
+ enabled: boolean;
765
+ }
738
766
  export interface SetLayersPayload {
739
767
  flightId: number;
740
768
  layers: Layer[];
@@ -1094,6 +1122,15 @@ export type ClientMessage =
1094
1122
  | {
1095
1123
  type: "set-throttle-main-screen";
1096
1124
  content: SetThrottleMainScreenPayload;
1125
+ }
1126
+ /**
1127
+ * Start or stop `FrameStamp` messages on this peer's control channel.
1128
+ * Off until asked for, so clients that predate stamps never receive
1129
+ * them. Send only to a sidecar whose `Hello` has `frameStamps: true`.
1130
+ */
1131
+ | {
1132
+ type: "set-frame-stamps";
1133
+ content: SetFrameStampsPayload;
1097
1134
  };
1098
1135
  /** Messages sent FROM the sidecar TO the client. */
1099
1136
  export type ServerMessage =
@@ -1179,7 +1216,8 @@ export type ServerMessage =
1179
1216
  }
1180
1217
  /**
1181
1218
  * Capture UT of one frame about to go out on a video track, keyed by
1182
- * its RTP timestamp. One per frame per track.
1219
+ * its RTP timestamp. One per frame per track, only to a peer that
1220
+ * enabled them with `SetFrameStamps`.
1183
1221
  */
1184
1222
  | {
1185
1223
  type: "frame-stamp";
@@ -172,4 +172,15 @@ export var ErrorCode;
172
172
  ErrorCode["CameraDestroyed"] = "camera-destroyed";
173
173
  /** A command authority in game holds the command cameras' aim lock. */
174
174
  ErrorCode["CommandLocked"] = "command-locked";
175
+ /**
176
+ * A command aim names a craft that is not in the camera's latest
177
+ * `commandCentre.targets`, for example one merged away in a dock.
178
+ */
179
+ ErrorCode["CommandTargetUnknown"] = "command-target-unknown";
180
+ /**
181
+ * `POST /offer` refused: the offer's video carries no H.264 with
182
+ * packetization-mode=1, the only video Kerbcast sends. A browser
183
+ * without H.264 support (some Firefox builds lack OpenH264) offers none.
184
+ */
185
+ ErrorCode["CodecUnsupported"] = "codec-unsupported";
175
186
  })(ErrorCode || (ErrorCode = {}));
package/dist/client.d.ts CHANGED
@@ -92,6 +92,20 @@ export interface KerbcastCameraHandle {
92
92
  * without noise so only the text overlay renders.
93
93
  */
94
94
  setShowStatic(enabled: boolean): void;
95
+ /**
96
+ * Run a transform on this camera's encoded frames before they are decoded,
97
+ * and so before Kerbcast's static and frame lock see them. Kerbcast calls
98
+ * `factory` for a fresh instance each time the camera lands on a receiver
99
+ * (connect, slot rebind, reconnect) and removes it from a receiver the
100
+ * camera leaves; frames the old instance still holds are discarded. The
101
+ * setting persists across reconnects. `null` detaches.
102
+ *
103
+ * Needs {@link KerbcastClientConfig.encodedTransforms}; throws without it,
104
+ * or when the browser has no install path. Failures while installing
105
+ * (the factory throws, or returns the wrong kind for the path) surface as
106
+ * client `error` events.
107
+ */
108
+ setEncodedTransform(factory: EncodedTransformFactory | null): void;
95
109
  on<K extends keyof KerbcastCameraEvents>(event: K, handler: (data: KerbcastCameraEvents[K]) => void): () => void;
96
110
  }
97
111
  /** Event payloads emitted by a {@link KerbcastCameraHandle}. */
@@ -103,6 +117,26 @@ export interface KerbcastCameraEvents {
103
117
  /** Frames stopped arriving on a live source (`true`) / resumed (`false`). */
104
118
  stall: boolean;
105
119
  }
120
+ /**
121
+ * How a peer installs encoded transforms. `"script"` sets an
122
+ * `RTCRtpScriptTransform` as the receiver's `transform`, used wherever the
123
+ * browser has it; `"streams"` runs a main-thread `TransformStream` over the
124
+ * receiver's `createEncodedStreams()`, used on Chrome builds without it.
125
+ */
126
+ export type EncodedTransformPath = "script" | "streams";
127
+ /** A transform over encoded video frames: an `RTCRtpScriptTransform` on the `"script"` path, a `TransformStream` on `"streams"`. */
128
+ export type EncodedTransform = RTCRtpScriptTransform | TransformStream<RTCEncodedVideoFrame, RTCEncodedVideoFrame>;
129
+ /** Passed to an {@link EncodedTransformFactory}. */
130
+ export interface EncodedTransformContext {
131
+ flightId: number;
132
+ /** Which kind of {@link EncodedTransform} to return. */
133
+ path: EncodedTransformPath;
134
+ }
135
+ /**
136
+ * Builds a transform instance for one receiver. An instance is single-use,
137
+ * so Kerbcast asks for a new one whenever the camera moves receiver.
138
+ */
139
+ export type EncodedTransformFactory = (ctx: EncodedTransformContext) => EncodedTransform;
106
140
  /** Configuration for {@link KerbcastClient} construction. */
107
141
  export interface KerbcastClientConfig {
108
142
  /** Sidecar host (matches the sidecar's `--http-bind`). */
@@ -130,6 +164,18 @@ export interface KerbcastClientConfig {
130
164
  * Default false.
131
165
  */
132
166
  frameLock?: boolean;
167
+ /**
168
+ * Enable {@link KerbcastCameraHandle.setEncodedTransform}. Every receiver
169
+ * then carries a passthrough transform from creation, because Chrome only
170
+ * honours a receiver transform that was in place before negotiation and
171
+ * stops a receiver's frames when its transform is cleared. This routes
172
+ * every camera's frames through a worker (or, on Chrome builds without
173
+ * `RTCRtpScriptTransform`, a main-thread `createEncodedStreams` pipe), so
174
+ * it is off by default. The worker is a `blob:` URL, so a page with a
175
+ * Content Security Policy must allow `worker-src blob:`. Set it before
176
+ * connecting; a peer keeps the path it was created with.
177
+ */
178
+ encodedTransforms?: boolean;
133
179
  /**
134
180
  * ICE gathering timeout in milliseconds. Passed to the default
135
181
  * {@link BrowserKerbcastTransport}; ignored when a custom transport is
@@ -256,6 +302,27 @@ export declare class CommandLockedError extends Error {
256
302
  readonly code = ErrorCode.CommandLocked;
257
303
  constructor(owner: string);
258
304
  }
305
+ /**
306
+ * {@link KerbcastClient.setCommandAim} refused because the aim's craft (a
307
+ * `vessel` aim's craft, or an `orbit` or `positionAt` aim's subject) is not in
308
+ * the camera's `commandCentre.targets`, for example one merged away in a dock.
309
+ */
310
+ export declare class CommandTargetUnknownError extends Error {
311
+ readonly flightId: number;
312
+ readonly persistentId: number;
313
+ readonly code = ErrorCode.CommandTargetUnknown;
314
+ constructor(flightId: number, persistentId: number);
315
+ }
316
+ /**
317
+ * The sidecar refused `POST /offer` with a reason, for example
318
+ * `ErrorCode.CodecUnsupported` when the browser offers no H.264 video.
319
+ */
320
+ export declare class OfferRefusedError extends Error {
321
+ readonly status: number;
322
+ readonly reason: string;
323
+ readonly code?: ErrorCode | undefined;
324
+ constructor(status: number, reason: string, code?: ErrorCode | undefined);
325
+ }
259
326
  /** How long {@link KerbcastClient.createCommandCamera} waits for the camera by default. */
260
327
  export declare const COMMAND_CAMERA_CREATE_TIMEOUT_MS = 35000;
261
328
  /**
@@ -324,7 +391,17 @@ export interface KerbcastClientEvents {
324
391
  * implementation to exercise the state machine without a real peer.
325
392
  */
326
393
  export interface KerbcastTransport {
327
- createPeer(iceServers: RTCIceServer[]): KerbcastPeer;
394
+ createPeer(iceServers: RTCIceServer[], opts?: KerbcastPeerOptions): KerbcastPeer;
395
+ /**
396
+ * The encoded-transform path a peer created with `opts` would use, or null
397
+ * when it has none. Optional; a transport without it offers no path.
398
+ */
399
+ encodedTransformPath?(opts: KerbcastPeerOptions): EncodedTransformPath | null;
400
+ }
401
+ /** Peer creation options passed to {@link KerbcastTransport.createPeer}. */
402
+ export interface KerbcastPeerOptions {
403
+ /** Mirrors {@link KerbcastClientConfig.encodedTransforms}. */
404
+ encodedTransforms?: boolean;
328
405
  }
329
406
  export interface KerbcastPeer {
330
407
  addRecvOnlyTransceiver(): void;
@@ -343,6 +420,12 @@ export interface KerbcastPeer {
343
420
  * changes. Equivalent to `RTCPeerConnection.getStats(null)`.
344
421
  */
345
422
  getStats?(): Promise<RTCStatsReport>;
423
+ /**
424
+ * Install `transform` on the receiver of slot `mid`, replacing any
425
+ * previous one; `null` restores passthrough. Throws when the receiver is
426
+ * unknown or the transform's kind does not match the peer's path.
427
+ */
428
+ setReceiverTransform?(mid: string, transform: EncodedTransform | null): void;
346
429
  }
347
430
  export interface KerbcastDataChannel {
348
431
  send(payload: string): void;
@@ -365,7 +448,8 @@ export interface BrowserKerbcastTransportOptions {
365
448
  export declare class BrowserKerbcastTransport implements KerbcastTransport {
366
449
  private readonly opts;
367
450
  constructor(opts?: BrowserKerbcastTransportOptions);
368
- createPeer(iceServers: RTCIceServer[]): KerbcastPeer;
451
+ encodedTransformPath(opts: KerbcastPeerOptions): EncodedTransformPath | null;
452
+ createPeer(iceServers: RTCIceServer[], opts?: KerbcastPeerOptions): KerbcastPeer;
369
453
  }
370
454
  declare class TypedEmitter<E> {
371
455
  private readonly listeners;
@@ -424,6 +508,12 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
424
508
  * matches by mid instead).
425
509
  */
426
510
  private readonly flightByTrackId;
511
+ /** Legacy mode: transceiver mid -> the camera routed to it by index. */
512
+ private readonly legacyFlightByMid;
513
+ /** The encoded-transform path the current peer was created with. */
514
+ private peerTransformPath;
515
+ /** Receivers holding a camera's encoded transform, by mid, with the factory that built it. */
516
+ private readonly transformInstalls;
427
517
  /** Sidecar version reported on `Hello`; null before handshake. */
428
518
  private _sidecarVersion;
429
519
  private _encoderBackend;
@@ -634,6 +724,11 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
634
724
  * (`commandTrack.authority`): it holds the lock and only it can aim. A
635
725
  * refusal that races the lock being taken arrives as an `error` event with
636
726
  * code `ErrorCode.CommandLocked` instead.
727
+ *
728
+ * Rejects with a {@link CommandTargetUnknownError} when the aim's craft is
729
+ * not in the camera's `commandCentre.targets` (a craft merged away in a
730
+ * dock, say). A refusal that races the list changing arrives as an `error`
731
+ * event with code `ErrorCode.CommandTargetUnknown`.
637
732
  */
638
733
  setCommandAim(flightId: number, aim: CommandAim | null): Promise<void>;
639
734
  /**
@@ -696,6 +791,20 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
696
791
  * (modern browsers include it; some implementations omit it).
697
792
  */
698
793
  inboundVideoStats(): Promise<Map<number, InboundVideoStats>>;
794
+ /** The encoded-transform path this client's next peer will use, or null when it has none. */
795
+ get encodedTransformPath(): EncodedTransformPath | null;
796
+ private peerOptions;
797
+ /** Internal: throws unless {@link KerbcastCameraHandle.setEncodedTransform} can work here. */
798
+ _assertEncodedTransforms(): void;
799
+ /**
800
+ * Internal: bring each receiver's installed transform in line with the
801
+ * camera now bound to it. A receiver whose camera or factory changed loses
802
+ * its instance; a bound camera with a factory gets a fresh one.
803
+ */
804
+ _syncEncodedTransforms(): void;
805
+ private clearEncodedTransforms;
806
+ private setReceiverTransform;
807
+ private emitTransformError;
699
808
  private tearDownStreams;
700
809
  }
701
810
  export {};