@ksp-gonogo/kerbcast 1.6.3 → 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;
@@ -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,
@@ -248,6 +312,21 @@ export interface SetThrottleMainScreenPayload {
248
312
  */
249
313
  enabled: boolean;
250
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
+ }
251
330
  export interface SetZoomRatePayload {
252
331
  flightId: number;
253
332
  /**
@@ -324,13 +403,38 @@ export type ClientMessage =
324
403
  content: SetLayersPayload;
325
404
  }
326
405
  /**
327
- * Override the operator render size for one camera. Even pixels
328
- * 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.
329
411
  */
330
412
  | {
331
413
  type: "set-render-size";
332
414
  content: SetRenderSizePayload;
333
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
+ }
334
438
  /**
335
439
  * Set the camera's field-of-view (degrees). Silently ignored for
336
440
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -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
@@ -658,6 +658,27 @@ export class KerbcastClient extends TypedEmitter {
658
658
  async unsubscribe(flightId) {
659
659
  await this._send({ type: "unsubscribe", content: { flightId } });
660
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
+ }
661
682
  /**
662
683
  * Internal — resolves the effective noise enabled state for a camera,
663
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.3",
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;
@@ -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,
@@ -272,6 +340,22 @@ export interface SetThrottleMainScreenPayload {
272
340
  enabled: boolean;
273
341
  }
274
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
+
275
359
  export interface SetZoomRatePayload {
276
360
  flightId: number;
277
361
  /**
@@ -345,10 +429,29 @@ export type ClientMessage =
345
429
  */
346
430
  | { type: "set-layers", content: SetLayersPayload }
347
431
  /**
348
- * Override the operator render size for one camera. Even pixels
349
- * 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.
350
437
  */
351
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 }
352
455
  /**
353
456
  * Set the camera's field-of-view (degrees). Silently ignored for
354
457
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -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";
@@ -1040,6 +1041,29 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
1040
1041
  await this._send({ type: "unsubscribe", content: { flightId } });
1041
1042
  }
1042
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
+
1043
1067
  /**
1044
1068
  * Internal — resolves the effective noise enabled state for a camera,
1045
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] });