@ksp-gonogo/kerbcast 1.6.2 → 1.7.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 - 2026-07-24
4
+
5
+ Crew face cameras, auto-resolution, and camera targeting.
6
+
7
+ ### Added
8
+
9
+ - `KerbcastClient.reportDisplaySize(flightId, width, height)`: report a
10
+ consumer's own displayed pixel size for a camera. The sidecar aggregates it
11
+ MAX-across-consumers to drive auto-resolution (distinct from `setRenderSize`,
12
+ which is the shared operator cap).
13
+ - `KerbcastClient.setTrackTarget(flightId, mode)`: ask a pan+zoom camera to
14
+ auto-track a moving vessel, or stop.
15
+ - `TrackMode` type (`"none" | "activeVessel" | "target"`).
16
+ - `trackMode?: TrackMode` on `CameraState` (WS push) and `CameraInfo`
17
+ (`GET /cameras`), server-authoritative so every consumer reflects the same
18
+ tracking state.
19
+ - `MockSidecar` accepts the new `report-display-size` and `set-track-target`
20
+ client messages (records them; `report-display-size` is advisory and does not
21
+ mutate render dims).
22
+ - `MockCameraInit` gains `kind`, `crewLocation`, `kerbalPersistentId`, and
23
+ `trackMode` for building crew and tracking fixtures.
24
+
3
25
  ## 0.12.0 - 2026-06-07
4
26
 
5
27
  ### Added
package/README.md CHANGED
@@ -106,6 +106,22 @@ controls each part actually supports.
106
106
  skip, producing the macroblocking and stuttering aesthetic of
107
107
  in-game CommNet signal loss. Also a real CPU optimisation when
108
108
  every consumer wants degraded video.
109
+ - `kind` (`"part"` | `"kerbal"`) and, for a kerbal face camera,
110
+ `crewLocation` (`"seat"` | `"eva"`). Distinguish crew face cameras
111
+ from part cameras.
112
+ - `trackMode` (`"none"` | `"activeVessel"` | `"target"`). Server-authoritative
113
+ auto-track state on a pan+zoom camera, so every consumer reflects the same
114
+ tracking state.
115
+
116
+ ## Auto-resolution and targeting
117
+
118
+ - `client.reportDisplaySize(flightId, width, height)`. Report a consumer's
119
+ displayed pixel size for a camera; the sidecar takes the MAX across consumers
120
+ to size the encode (distinct from `setRenderSize`, the shared operator cap).
121
+ The react feed primitives self-measure and call this for you.
122
+ - `client.setTrackTarget(flightId, mode)`. Ask a pan+zoom camera to auto-track
123
+ the active vessel or its target (`TrackMode`), or `"none"` to stop. The
124
+ chosen mode is published back on `CameraState.trackMode`.
109
125
 
110
126
  ## Pan/zoom input helper
111
127
 
@@ -14,6 +14,34 @@ export declare enum CameraLifecycle {
14
14
  Active = "active",
15
15
  Destroyed = "destroyed"
16
16
  }
17
+ /**
18
+ * Distinguishes an existing Hullcam part camera (`Part`) from a
19
+ * per-kerbal face camera (`Kerbal`).
20
+ */
21
+ export declare enum CameraKind {
22
+ Part = "part",
23
+ Kerbal = "kerbal"
24
+ }
25
+ /**
26
+ * Which source a kerbal camera is currently rendering: a seated IVA
27
+ * portrait, or the kerbal's own view while on EVA. Only meaningful when
28
+ * `CameraState.kind == Kerbal`.
29
+ */
30
+ export declare enum CrewLocation {
31
+ Seat = "seat",
32
+ Eva = "eva"
33
+ }
34
+ /**
35
+ * Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
36
+ * off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
37
+ * position from `FlightGlobals` (the active vessel, or its target) with no
38
+ * orbit math. Only surfaces on cameras with BOTH pan and zoom.
39
+ */
40
+ export declare enum TrackMode {
41
+ None = "none",
42
+ ActiveVessel = "activeVessel",
43
+ Target = "target"
44
+ }
17
45
  /**
18
46
  * Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
19
47
  * Receiving clients use this for both the rendered-layer status reports
@@ -62,6 +90,29 @@ export interface CameraState {
62
90
  * surface a "SIGNAL LOST" overlay and keep the last frame visible.
63
91
  */
64
92
  lifecycle?: CameraLifecycle;
93
+ /**
94
+ * Whether this is a Hullcam part camera or a per-kerbal face camera.
95
+ * Defaults to `Part` so existing payloads are unaffected.
96
+ */
97
+ kind?: CameraKind;
98
+ /**
99
+ * The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
100
+ * consumer correlates against. `None` for part cameras. Distinct from
101
+ * `flight_id`, which identifies the camera instance, not the kerbal.
102
+ */
103
+ kerbalPersistentId?: number;
104
+ /**
105
+ * Which source a kerbal camera is currently rendering. `None` for
106
+ * part cameras.
107
+ */
108
+ crewLocation?: CrewLocation;
109
+ /**
110
+ * Server-authoritative auto-track state for a pan+zoom camera (the
111
+ * operator's persistent intent). `None` (the default) = not tracking.
112
+ * Every browser reflects this; a browser track overrides a kOS aim on the
113
+ * same camera (send `SetTrackTarget` with `none` to hand back to kOS).
114
+ */
115
+ trackMode?: TrackMode;
65
116
  partName: string;
66
117
  partTitle: string;
67
118
  cameraName: string;
@@ -131,8 +182,8 @@ export interface CameraState {
131
182
  */
132
183
  targetBitrateBps: number;
133
184
  /**
134
- * Effective degrade level (max across subscribers' SetDegrade
135
- * requests). 0.0 = perfect, 1.0 = max degradation. Applied
185
+ * Effective degrade level (last-writer-wins across subscribers'
186
+ * SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
136
187
  * alongside operator render-size + adaptive shed; the encoder
137
188
  * multiplies its effective bitrate by `(1 - 0.7 * level)` and
138
189
  * skips fan-out for a fraction of frames at high levels.
@@ -175,6 +226,19 @@ export interface HelloPayload {
175
226
  sidecarVersion: string;
176
227
  encoderBackend: string;
177
228
  }
229
+ /**
230
+ * A consumer reporting its OWN current display size in px for one camera.
231
+ * Unlike `SetRenderSizePayload` (an operator command that sets the shared
232
+ * render size directly, last-writer-wins), this is a per-consumer input the
233
+ * sidecar aggregates MAX-across-consumers to drive auto-resolution
234
+ * (meet-the-minimum-need). Shape mirrors `SetRenderSizePayload`; `width` and
235
+ * `height` are the consumer's display px for this feed.
236
+ */
237
+ export interface ReportDisplaySizePayload {
238
+ flightId: number;
239
+ width: number;
240
+ height: number;
241
+ }
178
242
  /**
179
243
  * Scene-state change: whether KSP is currently in a flight scene. Sent
180
244
  * after `Hello` (priming) and whenever the polled in-flight flag flips,
@@ -187,10 +251,12 @@ export interface SceneStateChangedPayload {
187
251
  export interface SetDegradePayload {
188
252
  flightId: number;
189
253
  /**
190
- * 0.0 = perfect quality, 1.0 = maximum degradation. Caps and
191
- * is per-subscriber: the sidecar applies max across active
192
- * subscribers so the noisiest consumer's request wins (same
193
- * pattern as REMB picking the min bandwidth).
254
+ * 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
255
+ * Last-writer-wins: degrade is a property of the camera's game
256
+ * state (comms strength), shared by all viewers of the single
257
+ * encoded stream, so the sidecar stores the latest value rather
258
+ * than reducing across subscribers. A well-behaved driver
259
+ * re-asserts every tick.
194
260
  */
195
261
  level: number;
196
262
  }
@@ -246,6 +312,21 @@ export interface SetThrottleMainScreenPayload {
246
312
  */
247
313
  enabled: boolean;
248
314
  }
315
+ /**
316
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
317
+ * `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
318
+ * chosen mode per camera and publishes it in `CameraState.track_mode`, so
319
+ * every browser reflects the same tracking state (never optimistic-local).
320
+ *
321
+ * Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
322
+ * same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
323
+ * camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
324
+ * zoom over any manual FoV; `mode: none` restores manual zoom untouched.
325
+ */
326
+ export interface SetTrackTargetPayload {
327
+ flightId: number;
328
+ mode: TrackMode;
329
+ }
249
330
  export interface SetZoomRatePayload {
250
331
  flightId: number;
251
332
  /**
@@ -322,13 +403,38 @@ export type ClientMessage =
322
403
  content: SetLayersPayload;
323
404
  }
324
405
  /**
325
- * Override the operator render size for one camera. Even pixels
326
- * only (H.264 chroma); server caps at the ring's allocated max.
406
+ * Cap the auto-resolution for one camera. As of Stage 6 this is a
407
+ * CEILING on the demand-driven size (effective = min(auto max-across-
408
+ * consumers, this cap)), NOT an authoritative set: it cannot force a
409
+ * camera larger than current consumers report, only smaller. Even
410
+ * pixels only (H.264 chroma); server caps at the ring's allocated max.
327
411
  */
328
412
  | {
329
413
  type: "set-render-size";
330
414
  content: SetRenderSizePayload;
331
415
  }
416
+ /**
417
+ * Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
418
+ * holds the chosen `mode` per camera and publishes it in
419
+ * `CameraState.track_mode`, so tracking state is server-authoritative and
420
+ * consistent across browsers. A browser track OVERRIDES a kOS aim on the
421
+ * same camera; send `mode: none` to hand aiming back to kOS.
422
+ */
423
+ | {
424
+ type: "set-track-target";
425
+ content: SetTrackTargetPayload;
426
+ }
427
+ /**
428
+ * A consumer reporting its OWN current display size in px; the sidecar
429
+ * aggregates MAX-across-consumers to drive auto-resolution
430
+ * (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
431
+ * command: this is a per-consumer input, not a shared override — a
432
+ * departing consumer's size is cleared so the max relaxes.
433
+ */
434
+ | {
435
+ type: "report-display-size";
436
+ content: ReportDisplaySizePayload;
437
+ }
332
438
  /**
333
439
  * Set the camera's field-of-view (degrees). Silently ignored for
334
440
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -379,11 +485,12 @@ export type ClientMessage =
379
485
  }
380
486
  /**
381
487
  * Request artificial signal degradation. 0.0 = perfect quality,
382
- * 1.0 = maximum degradation. Per-subscriber: the sidecar
383
- * applies max across active subscribers (slowest consumer
384
- * wins, same pattern as REMB bandwidth). Lets consumers signal
385
- * to the sidecar "I want to look like the in-game CommNet
386
- * antenna is struggling"; sidecar takes the opportunity to
488
+ * 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
489
+ * the latest value on the camera (degrade is shared across all
490
+ * viewers of the one encoded stream), so a departed peer can't
491
+ * pin it. Lets consumers signal to the sidecar "I want to look
492
+ * like the in-game CommNet antenna is struggling"; sidecar takes
493
+ * the opportunity to
387
494
  * reduce bitrate + skip frames, which both creates the
388
495
  * signal-loss aesthetic AND saves encoder CPU.
389
496
  */
@@ -13,6 +13,37 @@ export var CameraLifecycle;
13
13
  CameraLifecycle["Active"] = "active";
14
14
  CameraLifecycle["Destroyed"] = "destroyed";
15
15
  })(CameraLifecycle || (CameraLifecycle = {}));
16
+ /**
17
+ * Distinguishes an existing Hullcam part camera (`Part`) from a
18
+ * per-kerbal face camera (`Kerbal`).
19
+ */
20
+ export var CameraKind;
21
+ (function (CameraKind) {
22
+ CameraKind["Part"] = "part";
23
+ CameraKind["Kerbal"] = "kerbal";
24
+ })(CameraKind || (CameraKind = {}));
25
+ /**
26
+ * Which source a kerbal camera is currently rendering: a seated IVA
27
+ * portrait, or the kerbal's own view while on EVA. Only meaningful when
28
+ * `CameraState.kind == Kerbal`.
29
+ */
30
+ export var CrewLocation;
31
+ (function (CrewLocation) {
32
+ CrewLocation["Seat"] = "seat";
33
+ CrewLocation["Eva"] = "eva";
34
+ })(CrewLocation || (CrewLocation = {}));
35
+ /**
36
+ * Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
37
+ * off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
38
+ * position from `FlightGlobals` (the active vessel, or its target) with no
39
+ * orbit math. Only surfaces on cameras with BOTH pan and zoom.
40
+ */
41
+ export var TrackMode;
42
+ (function (TrackMode) {
43
+ TrackMode["None"] = "none";
44
+ TrackMode["ActiveVessel"] = "activeVessel";
45
+ TrackMode["Target"] = "target";
46
+ })(TrackMode || (TrackMode = {}));
16
47
  /**
17
48
  * Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
18
49
  * Receiving clients use this for both the rendered-layer status reports
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload } from "./__generated__/types";
1
+ import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, Layer, QualityPreset, SettingsStatePayload, TrackMode } from "./__generated__/types";
2
2
  import { CameraLifecycle } from "./__generated__/types";
3
3
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
4
4
  export interface NoiseConfig {
@@ -409,6 +409,23 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
409
409
  * goes null once the sidecar confirms with a SlotMap.
410
410
  */
411
411
  unsubscribe(flightId: number): Promise<void>;
412
+ /**
413
+ * Report this consumer's own displayed pixel size for a camera, driving the
414
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
415
+ * sets the shared render size directly, last-writer-wins), this is a
416
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
417
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
418
+ * feed primitives self-measure and call this; consumers do not report by hand.
419
+ */
420
+ reportDisplaySize(flightId: number, width: number, height: number): Promise<void>;
421
+ /**
422
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
423
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
424
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
425
+ * rather than this call (two browsers stay in agreement). While tracking, the
426
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
427
+ */
428
+ setTrackTarget(flightId: number, mode: TrackMode): Promise<void>;
412
429
  /**
413
430
  * Internal — resolves the effective noise enabled state for a camera,
414
431
  * merging the per-camera override (if any) with the client-level default.
package/dist/client.js CHANGED
@@ -4,6 +4,13 @@ import { tryCreateNoisePipeline } from "./noise";
4
4
  const SOURCELESS_INTENSITY = 1.0;
5
5
  /** Floor applied to the degrade-driven intensity of a live feed. */
6
6
  const LIVE_INTENSITY_FLOOR = 0.05;
7
+ /**
8
+ * While a feed is stalled, how often to re-request a keyframe. A stall is
9
+ * usually a wedged decoder that only a fresh IDR clears; the first request or
10
+ * its keyframe can be lost during the same outage, so we keep asking until
11
+ * frames resume.
12
+ */
13
+ const STALL_KEYFRAME_RETRY_MS = 2000;
7
14
  export class BrowserKerbcastTransport {
8
15
  constructor(opts = {}) {
9
16
  this.opts = { iceGatheringTimeoutMs: opts.iceGatheringTimeoutMs ?? 2000 };
@@ -159,6 +166,8 @@ class CameraHandle extends TypedEmitter {
159
166
  this._sourceless = false;
160
167
  /** Stall state mirrored from the pipeline's onStallChange callback. */
161
168
  this._stalled = false;
169
+ /** Interval that re-requests keyframes while stalled; null when not stalled. */
170
+ this._keyframeRetry = null;
162
171
  /** Whether animated static is drawn; survives pipeline rebuilds. */
163
172
  this._showStatic = true;
164
173
  this.flightId = flightId;
@@ -291,6 +300,26 @@ class CameraHandle extends TypedEmitter {
291
300
  return;
292
301
  this._stalled = stalled;
293
302
  this.emit("stall", stalled);
303
+ if (stalled) {
304
+ // A stall usually means the browser decoder is wedged (broken H.264
305
+ // reference chain after packet loss or heavy degrade), which only a
306
+ // fresh keyframe clears. Force one now and keep asking while stalled,
307
+ // rather than waiting on the browser's rate-limited PLI.
308
+ this._requestKeyframeForRecovery();
309
+ if (this._keyframeRetry === null) {
310
+ this._keyframeRetry = setInterval(() => this._requestKeyframeForRecovery(), STALL_KEYFRAME_RETRY_MS);
311
+ }
312
+ }
313
+ else if (this._keyframeRetry !== null) {
314
+ clearInterval(this._keyframeRetry);
315
+ this._keyframeRetry = null;
316
+ }
317
+ }
318
+ _requestKeyframeForRecovery() {
319
+ this.requestKeyframe().catch(() => {
320
+ // Best effort; a failed send (disconnected) is retried on the next tick,
321
+ // or cleared when the stall ends.
322
+ });
294
323
  }
295
324
  _liveIntensity() {
296
325
  return Math.max(LIVE_INTENSITY_FLOOR, this._state?.degradeLevel ?? 0);
@@ -300,6 +329,10 @@ class CameraHandle extends TypedEmitter {
300
329
  * the persistent pipeline, stops its rAF loop, and nulls the stream.
301
330
  */
302
331
  _teardown() {
332
+ if (this._keyframeRetry !== null) {
333
+ clearInterval(this._keyframeRetry);
334
+ this._keyframeRetry = null;
335
+ }
303
336
  this._noisePipeline?.destroy();
304
337
  this._noisePipeline = null;
305
338
  this._rawStream = null;
@@ -625,6 +658,27 @@ export class KerbcastClient extends TypedEmitter {
625
658
  async unsubscribe(flightId) {
626
659
  await this._send({ type: "unsubscribe", content: { flightId } });
627
660
  }
661
+ /**
662
+ * Report this consumer's own displayed pixel size for a camera, driving the
663
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
664
+ * sets the shared render size directly, last-writer-wins), this is a
665
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
666
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
667
+ * feed primitives self-measure and call this; consumers do not report by hand.
668
+ */
669
+ async reportDisplaySize(flightId, width, height) {
670
+ await this._send({ type: "report-display-size", content: { flightId, width, height } });
671
+ }
672
+ /**
673
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
674
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
675
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
676
+ * rather than this call (two browsers stay in agreement). While tracking, the
677
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
678
+ */
679
+ async setTrackTarget(flightId, mode) {
680
+ await this._send({ type: "set-track-target", content: { flightId, mode } });
681
+ }
628
682
  /**
629
683
  * Internal — resolves the effective noise enabled state for a camera,
630
684
  * merging the per-camera override (if any) with the client-level default.
@@ -1,9 +1,16 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
2
- import { CameraLifecycle, Layer, QualityPreset } from "../__generated__/types";
2
+ import { CameraKind, CameraLifecycle, CrewLocation, Layer, QualityPreset, TrackMode } from "../__generated__/types";
3
3
  import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
4
4
  export interface MockCameraInit {
5
5
  flightId: number;
6
6
  lifecycle?: CameraLifecycle;
7
+ /** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
8
+ * part-cam callers are unchanged. */
9
+ kind?: CameraKind;
10
+ /** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
11
+ crewLocation?: CrewLocation;
12
+ /** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
13
+ kerbalPersistentId?: number;
7
14
  partName?: string;
8
15
  partTitle?: string;
9
16
  cameraName?: string;
@@ -30,6 +37,8 @@ export interface MockCameraInit {
30
37
  degradeLevel?: number;
31
38
  viewerQuality?: QualityPreset;
32
39
  qualityLimitedBy?: string;
40
+ /** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
41
+ trackMode?: TrackMode;
33
42
  }
34
43
  /**
35
44
  * In-process protocol-level fake for the kerbcast sidecar.
@@ -61,6 +70,7 @@ export declare class MockSidecar {
61
70
  private _clientMsgHandler;
62
71
  private _stateHandler;
63
72
  private _onTrackHandler;
73
+ private _subscribeHandler;
64
74
  private _trackIdx;
65
75
  /** Slot mids available for the dynamic-subscription model. Override with
66
76
  * {@link withSlots} before connecting if a test needs a specific pool. */
@@ -134,6 +144,13 @@ export declare class MockSidecar {
134
144
  * `trackIdentifier` matching what the client received.
135
145
  */
136
146
  deliverTrack(mid: string, track: MediaStreamTrack): void;
147
+ /**
148
+ * Register a handler fired each time a `subscribe` binds a camera to a slot,
149
+ * with `(flightId, mid)`. A browser harness uses it to deliver that camera's
150
+ * track to the mid it was actually bound to — so tracks follow the real
151
+ * subscription order, not the registration/array order. Set before connecting.
152
+ */
153
+ onSubscribe(handler: (flightId: number, mid: string) => void): void;
137
154
  /** The slot mid currently carrying `flightId`, or undefined. */
138
155
  slotMidFor(flightId: number): string | undefined;
139
156
  /** Every `ClientMessage` received from the client, in order. */
@@ -1,8 +1,11 @@
1
- import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
1
+ import { CameraKind, CameraLifecycle, ErrorSource, Layer, QualityPreset, TrackMode } from "../__generated__/types";
2
2
  function buildCamera(init) {
3
3
  return {
4
4
  flightId: init.flightId,
5
5
  lifecycle: init.lifecycle ?? CameraLifecycle.Active,
6
+ kind: init.kind ?? CameraKind.Part,
7
+ crewLocation: init.crewLocation,
8
+ kerbalPersistentId: init.kerbalPersistentId,
6
9
  partName: init.partName ?? `part-${init.flightId}`,
7
10
  partTitle: init.partTitle ?? `Part ${init.flightId}`,
8
11
  cameraName: init.cameraName ?? `camera-${init.flightId}`,
@@ -29,6 +32,7 @@ function buildCamera(init) {
29
32
  degradeLevel: init.degradeLevel ?? 0,
30
33
  viewerQuality: init.viewerQuality,
31
34
  qualityLimitedBy: init.qualityLimitedBy,
35
+ trackMode: init.trackMode ?? TrackMode.None,
32
36
  };
33
37
  }
34
38
  /** Fraction of the operator render size each preset targets (mirrors the
@@ -233,6 +237,15 @@ export class MockSidecar {
233
237
  this._deliveredTracks.set(mid, track);
234
238
  this._onTrackHandler?.(track, this._trackIdx++, mid);
235
239
  }
240
+ /**
241
+ * Register a handler fired each time a `subscribe` binds a camera to a slot,
242
+ * with `(flightId, mid)`. A browser harness uses it to deliver that camera's
243
+ * track to the mid it was actually bound to — so tracks follow the real
244
+ * subscription order, not the registration/array order. Set before connecting.
245
+ */
246
+ onSubscribe(handler) {
247
+ this._subscribeHandler = handler;
248
+ }
236
249
  /** The slot mid currently carrying `flightId`, or undefined. */
237
250
  slotMidFor(flightId) {
238
251
  for (const [mid, fid] of this._slotBindings) {
@@ -439,6 +452,18 @@ export class MockSidecar {
439
452
  this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
440
453
  break;
441
454
  }
455
+ case "set-track-target": {
456
+ // Server-authoritative: hold the chosen mode and broadcast it back as
457
+ // camera-state-changed, mirroring the sidecar so every browser reflects
458
+ // the same trackMode (never optimistic-local).
459
+ const cam = this._cameras.get(msg.content.flightId);
460
+ if (cam) {
461
+ const updated = { ...cam, trackMode: msg.content.mode };
462
+ this._cameras.set(msg.content.flightId, updated);
463
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
464
+ }
465
+ break;
466
+ }
442
467
  case "subscribe": {
443
468
  const flightId = msg.content.flightId;
444
469
  const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
@@ -454,6 +479,9 @@ export class MockSidecar {
454
479
  type: "slot-map",
455
480
  content: { mid: freeMid, flightId },
456
481
  });
482
+ // Let a harness deliver this camera's track to the slot it was just
483
+ // bound to (so tracks follow the actual subscription, not array order).
484
+ this._subscribeHandler?.(flightId, freeMid);
457
485
  }
458
486
  break;
459
487
  }
@@ -479,6 +507,11 @@ export class MockSidecar {
479
507
  // the mock has no frame clock, so it doesn't model their *effect* on
480
508
  // panYaw/fov. They're still recorded in `_commands`, so consumer tests
481
509
  // can assert the command was sent via `lastCommand("set-pan-rate")`.
510
+ // Advisory per-consumer display-size input. The real sidecar aggregates
511
+ // it MAX-across-consumers to drive auto-resolution; the mock has no
512
+ // aggregator, so it just records the command (via `_commands`) and does
513
+ // NOT mutate camera render dims (unlike the operator `set-render-size`).
514
+ case "report-display-size":
482
515
  case "set-pan-rate":
483
516
  case "set-zoom-rate":
484
517
  case "hello":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
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
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -20,6 +20,37 @@ export enum CameraLifecycle {
20
20
  Destroyed = "destroyed",
21
21
  }
22
22
 
23
+ /**
24
+ * Distinguishes an existing Hullcam part camera (`Part`) from a
25
+ * per-kerbal face camera (`Kerbal`).
26
+ */
27
+ export enum CameraKind {
28
+ Part = "part",
29
+ Kerbal = "kerbal",
30
+ }
31
+
32
+ /**
33
+ * Which source a kerbal camera is currently rendering: a seated IVA
34
+ * portrait, or the kerbal's own view while on EVA. Only meaningful when
35
+ * `CameraState.kind == Kerbal`.
36
+ */
37
+ export enum CrewLocation {
38
+ Seat = "seat",
39
+ Eva = "eva",
40
+ }
41
+
42
+ /**
43
+ * Which moving thing a pan+zoom camera should auto-aim at. `None` = tracking
44
+ * off (manual pan resumes). `ActiveVessel` and `Target` resolve to a world
45
+ * position from `FlightGlobals` (the active vessel, or its target) with no
46
+ * orbit math. Only surfaces on cameras with BOTH pan and zoom.
47
+ */
48
+ export enum TrackMode {
49
+ None = "none",
50
+ ActiveVessel = "activeVessel",
51
+ Target = "target",
52
+ }
53
+
23
54
  /**
24
55
  * Layer mask. Mirrors `Kerbcast.CameraLayers` on the plugin side.
25
56
  * Receiving clients use this for both the rendered-layer status reports
@@ -70,6 +101,29 @@ export interface CameraState {
70
101
  * surface a "SIGNAL LOST" overlay and keep the last frame visible.
71
102
  */
72
103
  lifecycle?: CameraLifecycle;
104
+ /**
105
+ * Whether this is a Hullcam part camera or a per-kerbal face camera.
106
+ * Defaults to `Part` so existing payloads are unaffected.
107
+ */
108
+ kind?: CameraKind;
109
+ /**
110
+ * The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
111
+ * consumer correlates against. `None` for part cameras. Distinct from
112
+ * `flight_id`, which identifies the camera instance, not the kerbal.
113
+ */
114
+ kerbalPersistentId?: number;
115
+ /**
116
+ * Which source a kerbal camera is currently rendering. `None` for
117
+ * part cameras.
118
+ */
119
+ crewLocation?: CrewLocation;
120
+ /**
121
+ * Server-authoritative auto-track state for a pan+zoom camera (the
122
+ * operator's persistent intent). `None` (the default) = not tracking.
123
+ * Every browser reflects this; a browser track overrides a kOS aim on the
124
+ * same camera (send `SetTrackTarget` with `none` to hand back to kOS).
125
+ */
126
+ trackMode?: TrackMode;
73
127
  partName: string;
74
128
  partTitle: string;
75
129
  cameraName: string;
@@ -139,8 +193,8 @@ export interface CameraState {
139
193
  */
140
194
  targetBitrateBps: number;
141
195
  /**
142
- * Effective degrade level (max across subscribers' SetDegrade
143
- * requests). 0.0 = perfect, 1.0 = max degradation. Applied
196
+ * Effective degrade level (last-writer-wins across subscribers'
197
+ * SetDegrade requests). 0.0 = perfect, 1.0 = max degradation. Applied
144
198
  * alongside operator render-size + adaptive shed; the encoder
145
199
  * multiplies its effective bitrate by `(1 - 0.7 * level)` and
146
200
  * skips fan-out for a fraction of frames at high levels.
@@ -190,6 +244,20 @@ export interface HelloPayload {
190
244
  encoderBackend: string;
191
245
  }
192
246
 
247
+ /**
248
+ * A consumer reporting its OWN current display size in px for one camera.
249
+ * Unlike `SetRenderSizePayload` (an operator command that sets the shared
250
+ * render size directly, last-writer-wins), this is a per-consumer input the
251
+ * sidecar aggregates MAX-across-consumers to drive auto-resolution
252
+ * (meet-the-minimum-need). Shape mirrors `SetRenderSizePayload`; `width` and
253
+ * `height` are the consumer's display px for this feed.
254
+ */
255
+ export interface ReportDisplaySizePayload {
256
+ flightId: number;
257
+ width: number;
258
+ height: number;
259
+ }
260
+
193
261
  /**
194
262
  * Scene-state change: whether KSP is currently in a flight scene. Sent
195
263
  * after `Hello` (priming) and whenever the polled in-flight flag flips,
@@ -203,10 +271,12 @@ export interface SceneStateChangedPayload {
203
271
  export interface SetDegradePayload {
204
272
  flightId: number;
205
273
  /**
206
- * 0.0 = perfect quality, 1.0 = maximum degradation. Caps and
207
- * is per-subscriber: the sidecar applies max across active
208
- * subscribers so the noisiest consumer's request wins (same
209
- * pattern as REMB picking the min bandwidth).
274
+ * 0.0 = perfect quality, 1.0 = maximum degradation. Clamped.
275
+ * Last-writer-wins: degrade is a property of the camera's game
276
+ * state (comms strength), shared by all viewers of the single
277
+ * encoded stream, so the sidecar stores the latest value rather
278
+ * than reducing across subscribers. A well-behaved driver
279
+ * re-asserts every tick.
210
280
  */
211
281
  level: number;
212
282
  }
@@ -270,6 +340,22 @@ export interface SetThrottleMainScreenPayload {
270
340
  enabled: boolean;
271
341
  }
272
342
 
343
+ /**
344
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
345
+ * `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
346
+ * chosen mode per camera and publishes it in `CameraState.track_mode`, so
347
+ * every browser reflects the same tracking state (never optimistic-local).
348
+ *
349
+ * Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
350
+ * same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
351
+ * camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
352
+ * zoom over any manual FoV; `mode: none` restores manual zoom untouched.
353
+ */
354
+ export interface SetTrackTargetPayload {
355
+ flightId: number;
356
+ mode: TrackMode;
357
+ }
358
+
273
359
  export interface SetZoomRatePayload {
274
360
  flightId: number;
275
361
  /**
@@ -343,10 +429,29 @@ export type ClientMessage =
343
429
  */
344
430
  | { type: "set-layers", content: SetLayersPayload }
345
431
  /**
346
- * Override the operator render size for one camera. Even pixels
347
- * only (H.264 chroma); server caps at the ring's allocated max.
432
+ * Cap the auto-resolution for one camera. As of Stage 6 this is a
433
+ * CEILING on the demand-driven size (effective = min(auto max-across-
434
+ * consumers, this cap)), NOT an authoritative set: it cannot force a
435
+ * camera larger than current consumers report, only smaller. Even
436
+ * pixels only (H.264 chroma); server caps at the ring's allocated max.
348
437
  */
349
438
  | { type: "set-render-size", content: SetRenderSizePayload }
439
+ /**
440
+ * Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
441
+ * holds the chosen `mode` per camera and publishes it in
442
+ * `CameraState.track_mode`, so tracking state is server-authoritative and
443
+ * consistent across browsers. A browser track OVERRIDES a kOS aim on the
444
+ * same camera; send `mode: none` to hand aiming back to kOS.
445
+ */
446
+ | { type: "set-track-target", content: SetTrackTargetPayload }
447
+ /**
448
+ * A consumer reporting its OWN current display size in px; the sidecar
449
+ * aggregates MAX-across-consumers to drive auto-resolution
450
+ * (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
451
+ * command: this is a per-consumer input, not a shared override — a
452
+ * departing consumer's size is cleared so the max relaxes.
453
+ */
454
+ | { type: "report-display-size", content: ReportDisplaySizePayload }
350
455
  /**
351
456
  * Set the camera's field-of-view (degrees). Silently ignored for
352
457
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -385,11 +490,12 @@ export type ClientMessage =
385
490
  | { type: "set-zoom-rate", content: SetZoomRatePayload }
386
491
  /**
387
492
  * Request artificial signal degradation. 0.0 = perfect quality,
388
- * 1.0 = maximum degradation. Per-subscriber: the sidecar
389
- * applies max across active subscribers (slowest consumer
390
- * wins, same pattern as REMB bandwidth). Lets consumers signal
391
- * to the sidecar "I want to look like the in-game CommNet
392
- * antenna is struggling"; sidecar takes the opportunity to
493
+ * 1.0 = maximum degradation. Last-writer-wins: the sidecar stores
494
+ * the latest value on the camera (degrade is shared across all
495
+ * viewers of the one encoded stream), so a departed peer can't
496
+ * pin it. Lets consumers signal to the sidecar "I want to look
497
+ * like the in-game CommNet antenna is struggling"; sidecar takes
498
+ * the opportunity to
393
499
  * reduce bitrate + skip frames, which both creates the
394
500
  * signal-loss aesthetic AND saves encoder CPU.
395
501
  */
@@ -4,6 +4,7 @@ import {
4
4
  ErrorSource,
5
5
  Layer,
6
6
  QualityPreset,
7
+ TrackMode,
7
8
  } from "./__generated__/types";
8
9
  import {
9
10
  BrowserKerbcastTransport,
@@ -381,6 +382,44 @@ describe("KerbcastClient", () => {
381
382
  ]);
382
383
  });
383
384
 
385
+ it("reportDisplaySize sends a report-display-size command", async () => {
386
+ const { transport, captured } = makeFakeTransport();
387
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
388
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
389
+
390
+ await client.connect();
391
+ captured.dc?._open();
392
+ captured.dc!.sent.length = 0;
393
+
394
+ await client.reportDisplaySize(3, 40, 40);
395
+
396
+ expect(captured.dc?.sent).toEqual([
397
+ JSON.stringify({
398
+ type: "report-display-size",
399
+ content: { flightId: 3, width: 40, height: 40 },
400
+ }),
401
+ ]);
402
+ });
403
+
404
+ it("setTrackTarget sends a set-track-target command", async () => {
405
+ const { transport, captured } = makeFakeTransport();
406
+ const client = new KerbcastClient({ host: "h", port: 1 }, transport);
407
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(fakeAnswer());
408
+
409
+ await client.connect();
410
+ captured.dc?._open();
411
+ captured.dc!.sent.length = 0;
412
+
413
+ await client.setTrackTarget(5, TrackMode.ActiveVessel);
414
+
415
+ expect(captured.dc?.sent).toEqual([
416
+ JSON.stringify({
417
+ type: "set-track-target",
418
+ content: { flightId: 5, mode: TrackMode.ActiveVessel },
419
+ }),
420
+ ]);
421
+ });
422
+
384
423
  it("disconnect tears down peer + emits null MediaStream events", async () => {
385
424
  const { transport, captured } = makeFakeTransport();
386
425
  const client = new KerbcastClient({ host: "h", port: 1 }, transport);
package/src/client.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  QualityPreset,
8
8
  ServerMessage,
9
9
  SettingsStatePayload,
10
+ TrackMode,
10
11
  } from "./__generated__/types";
11
12
  import { CameraLifecycle, ErrorSource } from "./__generated__/types";
12
13
  import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
@@ -15,6 +16,13 @@ import { type NoisePipeline, tryCreateNoisePipeline } from "./noise";
15
16
  const SOURCELESS_INTENSITY = 1.0;
16
17
  /** Floor applied to the degrade-driven intensity of a live feed. */
17
18
  const LIVE_INTENSITY_FLOOR = 0.05;
19
+ /**
20
+ * While a feed is stalled, how often to re-request a keyframe. A stall is
21
+ * usually a wedged decoder that only a fresh IDR clears; the first request or
22
+ * its keyframe can be lost during the same outage, so we keep asking until
23
+ * frames resume.
24
+ */
25
+ const STALL_KEYFRAME_RETRY_MS = 2000;
18
26
 
19
27
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
20
28
  export interface NoiseConfig {
@@ -473,6 +481,8 @@ class CameraHandle
473
481
  private _sourceless = false;
474
482
  /** Stall state mirrored from the pipeline's onStallChange callback. */
475
483
  private _stalled = false;
484
+ /** Interval that re-requests keyframes while stalled; null when not stalled. */
485
+ private _keyframeRetry: ReturnType<typeof setInterval> | null = null;
476
486
  /** Whether animated static is drawn; survives pipeline rebuilds. */
477
487
  private _showStatic = true;
478
488
  private readonly client: KerbcastClient;
@@ -625,6 +635,29 @@ class CameraHandle
625
635
  if (this._stalled === stalled) return;
626
636
  this._stalled = stalled;
627
637
  this.emit("stall", stalled);
638
+ if (stalled) {
639
+ // A stall usually means the browser decoder is wedged (broken H.264
640
+ // reference chain after packet loss or heavy degrade), which only a
641
+ // fresh keyframe clears. Force one now and keep asking while stalled,
642
+ // rather than waiting on the browser's rate-limited PLI.
643
+ this._requestKeyframeForRecovery();
644
+ if (this._keyframeRetry === null) {
645
+ this._keyframeRetry = setInterval(
646
+ () => this._requestKeyframeForRecovery(),
647
+ STALL_KEYFRAME_RETRY_MS,
648
+ );
649
+ }
650
+ } else if (this._keyframeRetry !== null) {
651
+ clearInterval(this._keyframeRetry);
652
+ this._keyframeRetry = null;
653
+ }
654
+ }
655
+
656
+ private _requestKeyframeForRecovery(): void {
657
+ this.requestKeyframe().catch(() => {
658
+ // Best effort; a failed send (disconnected) is retried on the next tick,
659
+ // or cleared when the stall ends.
660
+ });
628
661
  }
629
662
 
630
663
  private _liveIntensity(): number {
@@ -636,6 +669,10 @@ class CameraHandle
636
669
  * the persistent pipeline, stops its rAF loop, and nulls the stream.
637
670
  */
638
671
  _teardown(): void {
672
+ if (this._keyframeRetry !== null) {
673
+ clearInterval(this._keyframeRetry);
674
+ this._keyframeRetry = null;
675
+ }
639
676
  this._noisePipeline?.destroy();
640
677
  this._noisePipeline = null;
641
678
  this._rawStream = null;
@@ -1004,6 +1041,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
1004
1041
  await this._send({ type: "unsubscribe", content: { flightId } });
1005
1042
  }
1006
1043
 
1044
+ /**
1045
+ * Report this consumer's own displayed pixel size for a camera, driving the
1046
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
1047
+ * sets the shared render size directly, last-writer-wins), this is a
1048
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
1049
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
1050
+ * feed primitives self-measure and call this; consumers do not report by hand.
1051
+ */
1052
+ async reportDisplaySize(flightId: number, width: number, height: number): Promise<void> {
1053
+ await this._send({ type: "report-display-size", content: { flightId, width, height } });
1054
+ }
1055
+
1056
+ /**
1057
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
1058
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
1059
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
1060
+ * rather than this call (two browsers stay in agreement). While tracking, the
1061
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
1062
+ */
1063
+ async setTrackTarget(flightId: number, mode: TrackMode): Promise<void> {
1064
+ await this._send({ type: "set-track-target", content: { flightId, mode } });
1065
+ }
1066
+
1007
1067
  /**
1008
1068
  * Internal — resolves the effective noise enabled state for a camera,
1009
1069
  * merging the per-camera override (if any) with the client-level default.
@@ -1,5 +1,5 @@
1
1
  import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, ServerMessage, SettingsStatePayload } from "../__generated__/types";
2
- import { CameraLifecycle, ErrorSource, Layer, QualityPreset } from "../__generated__/types";
2
+ import { CameraKind, CameraLifecycle, CrewLocation, ErrorSource, Layer, QualityPreset, TrackMode } from "../__generated__/types";
3
3
  import type {
4
4
  InboundVideoStats,
5
5
  KerbcastConnectionState,
@@ -11,6 +11,13 @@ import type {
11
11
  export interface MockCameraInit {
12
12
  flightId: number;
13
13
  lifecycle?: CameraLifecycle;
14
+ /** Part vs kerbal face camera. Defaults to `part` when omitted, so existing
15
+ * part-cam callers are unchanged. */
16
+ kind?: CameraKind;
17
+ /** Only meaningful for `kind: Kerbal`: seated IVA portrait vs EVA view. */
18
+ crewLocation?: CrewLocation;
19
+ /** Only meaningful for `kind: Kerbal`: informational raw persistentID. */
20
+ kerbalPersistentId?: number;
14
21
  partName?: string;
15
22
  partTitle?: string;
16
23
  cameraName?: string;
@@ -37,12 +44,17 @@ export interface MockCameraInit {
37
44
  degradeLevel?: number;
38
45
  viewerQuality?: QualityPreset;
39
46
  qualityLimitedBy?: string;
47
+ /** Server-authoritative auto-track mode. Defaults to `None` (untracked). */
48
+ trackMode?: TrackMode;
40
49
  }
41
50
 
42
51
  function buildCamera(init: MockCameraInit): CameraState {
43
52
  return {
44
53
  flightId: init.flightId,
45
54
  lifecycle: init.lifecycle ?? CameraLifecycle.Active,
55
+ kind: init.kind ?? CameraKind.Part,
56
+ crewLocation: init.crewLocation,
57
+ kerbalPersistentId: init.kerbalPersistentId,
46
58
  partName: init.partName ?? `part-${init.flightId}`,
47
59
  partTitle: init.partTitle ?? `Part ${init.flightId}`,
48
60
  cameraName: init.cameraName ?? `camera-${init.flightId}`,
@@ -69,6 +81,7 @@ function buildCamera(init: MockCameraInit): CameraState {
69
81
  degradeLevel: init.degradeLevel ?? 0,
70
82
  viewerQuality: init.viewerQuality,
71
83
  qualityLimitedBy: init.qualityLimitedBy,
84
+ trackMode: init.trackMode ?? TrackMode.None,
72
85
  };
73
86
  }
74
87
 
@@ -120,6 +133,9 @@ export class MockSidecar {
120
133
  private _onTrackHandler:
121
134
  | ((track: MediaStreamTrack, idx: number, mid: string) => void)
122
135
  | undefined;
136
+ private _subscribeHandler:
137
+ | ((flightId: number, mid: string) => void)
138
+ | undefined;
123
139
  private _trackIdx = 0;
124
140
  /** Slot mids available for the dynamic-subscription model. Override with
125
141
  * {@link withSlots} before connecting if a test needs a specific pool. */
@@ -295,6 +311,16 @@ export class MockSidecar {
295
311
  this._onTrackHandler?.(track, this._trackIdx++, mid);
296
312
  }
297
313
 
314
+ /**
315
+ * Register a handler fired each time a `subscribe` binds a camera to a slot,
316
+ * with `(flightId, mid)`. A browser harness uses it to deliver that camera's
317
+ * track to the mid it was actually bound to — so tracks follow the real
318
+ * subscription order, not the registration/array order. Set before connecting.
319
+ */
320
+ onSubscribe(handler: (flightId: number, mid: string) => void): void {
321
+ this._subscribeHandler = handler;
322
+ }
323
+
298
324
  /** The slot mid currently carrying `flightId`, or undefined. */
299
325
  slotMidFor(flightId: number): string | undefined {
300
326
  for (const [mid, fid] of this._slotBindings) {
@@ -520,6 +546,18 @@ export class MockSidecar {
520
546
  this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
521
547
  break;
522
548
  }
549
+ case "set-track-target": {
550
+ // Server-authoritative: hold the chosen mode and broadcast it back as
551
+ // camera-state-changed, mirroring the sidecar so every browser reflects
552
+ // the same trackMode (never optimistic-local).
553
+ const cam = this._cameras.get(msg.content.flightId);
554
+ if (cam) {
555
+ const updated: CameraState = { ...cam, trackMode: msg.content.mode };
556
+ this._cameras.set(msg.content.flightId, updated);
557
+ this._sendToClient({ type: "camera-state-changed", content: { state: updated } });
558
+ }
559
+ break;
560
+ }
523
561
  case "subscribe": {
524
562
  const flightId = msg.content.flightId;
525
563
  const freeMid = this._slotMids.find((m) => !this._slotBindings.has(m));
@@ -534,6 +572,9 @@ export class MockSidecar {
534
572
  type: "slot-map",
535
573
  content: { mid: freeMid, flightId },
536
574
  });
575
+ // Let a harness deliver this camera's track to the slot it was just
576
+ // bound to (so tracks follow the actual subscription, not array order).
577
+ this._subscribeHandler?.(flightId, freeMid);
537
578
  }
538
579
  break;
539
580
  }
@@ -559,6 +600,11 @@ export class MockSidecar {
559
600
  // the mock has no frame clock, so it doesn't model their *effect* on
560
601
  // panYaw/fov. They're still recorded in `_commands`, so consumer tests
561
602
  // can assert the command was sent via `lastCommand("set-pan-rate")`.
603
+ // Advisory per-consumer display-size input. The real sidecar aggregates
604
+ // it MAX-across-consumers to drive auto-resolution; the mock has no
605
+ // aggregator, so it just records the command (via `_commands`) and does
606
+ // NOT mutate camera render dims (unlike the operator `set-render-size`).
607
+ case "report-display-size":
562
608
  case "set-pan-rate":
563
609
  case "set-zoom-rate":
564
610
  case "hello":
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { CameraLifecycle, Layer } from "./__generated__/types";
2
+ import { CameraLifecycle, Layer, TrackMode } from "./__generated__/types";
3
3
  import { KerbcastClient } from "./index";
4
4
  import { MockSidecar } from "./testing/index";
5
5
 
@@ -121,6 +121,48 @@ describe("MockSidecar", () => {
121
121
  expect(last?.content.fov).toBe(60);
122
122
  });
123
123
 
124
+ it("accepts and records report-display-size as an advisory command", async () => {
125
+ const sidecar = new MockSidecar();
126
+ sidecar.addCamera({ flightId: 42 });
127
+
128
+ const client = new KerbcastClient(
129
+ { host: "localhost", port: 8088 },
130
+ sidecar.createTransport(),
131
+ );
132
+ await client.connect([42]);
133
+ sidecar.open();
134
+
135
+ // Must not throw on the new message type, and must be retrievable.
136
+ await client.reportDisplaySize(42, 40, 40);
137
+
138
+ const cmd = sidecar.lastCommand("report-display-size", 42);
139
+ expect(cmd?.content).toEqual({ flightId: 42, width: 40, height: 40 });
140
+ // Advisory: reporting a display size must NOT be translated into an
141
+ // operator set-render-size command (that path is the manual cap only).
142
+ expect(sidecar.commands.some((c) => c.type === "set-render-size")).toBe(false);
143
+ });
144
+
145
+ it("set-track-target updates trackMode and echoes it to the client (server-authoritative)", async () => {
146
+ const sidecar = new MockSidecar();
147
+ sidecar.addCamera({ flightId: 7, supportsPan: true, supportsZoom: true });
148
+
149
+ const client = new KerbcastClient(
150
+ { host: "localhost", port: 8088 },
151
+ sidecar.createTransport(),
152
+ );
153
+ await client.connect([7]);
154
+ sidecar.open();
155
+
156
+ await client.setTrackTarget(7, TrackMode.Target);
157
+ expect(sidecar.lastCommand("set-track-target", 7)?.content.mode).toBe(TrackMode.Target);
158
+ // The mock echoes camera-state-changed so every client reflects the same
159
+ // server-held mode (never optimistic-local).
160
+ expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.Target);
161
+
162
+ await client.setTrackTarget(7, TrackMode.None);
163
+ expect(client.cameras.find((c) => c.flightId === 7)?.trackMode).toBe(TrackMode.None);
164
+ });
165
+
124
166
  it("updateCamera pushes state-change to the client", async () => {
125
167
  const sidecar = new MockSidecar();
126
168
  sidecar.addCamera({ flightId: 42, layers: [Layer.Near] });