@ksp-gonogo/kerbcast 1.6.3 → 1.8.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,66 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased (next minor)
4
+
5
+ Client-side camera recording (pure SDK; no sidecar/plugin/protocol change).
6
+
7
+ ### Added
8
+
9
+ - `KerbcastClient.recording`: a `RecordingController` that records a camera's
10
+ already-received WebRTC track with the browser's `MediaRecorder`, entirely
11
+ client-local (the sidecar never learns a recording happened)
12
+ - Per-feed API: `startRecording(flightId, opts?)` returns a client-minted
13
+ `recordingId`; `stopRecording(recordingId)` resolves a `RecordingHandle`;
14
+ `fetchRecording` / `discardRecording` / `isRecording`
15
+ - `RecordingHandle = { recordingId, blob, mimeType, utStart?, utEnd?,
16
+ utSamples, byteSize, durationMs }`, where `utSamples` is a ~1Hz
17
+ (recording-time, UT) series so a consumer can interpolate any frame's
18
+ mission-time near-frame-tight; out of flight it falls back to wall-clock
19
+ timing (`ut` absent) and upgrades automatically once UT appears
20
+ - Mime negotiation prefers `video/mp4;codecs=avc1` where
21
+ `MediaRecorder.isTypeSupported`, else `video/webm;codecs=vp9`, else
22
+ `video/webm`; the chosen type is on the handle. `opts.maxDurationMs` auto-stops
23
+ - Grouped API: `startGroupedRecording(flightIds, opts?)` (all recorders open in
24
+ one tick) and `stopGroupedRecording(groupId, { targetUt? })`. `targetUt`
25
+ enforces a coverage guarantee: finalization waits until every feed's latest
26
+ UT sample passes it, so no feed falls short of the common window
27
+ - `GroupedRecordingHandle = { groupId, recordings, commonUtWindow? }`, where
28
+ `commonUtWindow` is the intersection of the feeds' `[utStart, utEnd]`
29
+ - Grouped clips are physically remux-trimmed to `commonUtWindow` by default
30
+ (keyframe-boundary cut, no re-encode) via a lazily `import()`-ed Mediabunny,
31
+ loaded only on grouped-record start (single recordings stay dependency-free);
32
+ a clip that can't be cut cleanly (webm, or a remux error) degrades to
33
+ metadata-only and never fails the recording. `utSamples` + `commonUtWindow`
34
+ still ride along after trimming so a consumer can align to telemetry
35
+ - Helpers `negotiateMimeType`, `commonUtWindow`, `utToRecordingTimeMs`
36
+ - `StubMediaRecorder`: a jsdom `MediaRecorder` shim installed by
37
+ `installDomStubs`, so the full record -> UT-sample -> stop -> handle path is
38
+ headless-testable (its `isTypeSupported` is controllable for mime tests)
39
+ - `mediabunny` as a dependency, dynamically imported for grouped-clip trimming
40
+ only (never pulled by single recordings or non-recording consumers)
41
+
42
+ ## 1.7.0 - 2026-07-24
43
+
44
+ Crew face cameras, auto-resolution, and camera targeting.
45
+
46
+ ### Added
47
+
48
+ - `KerbcastClient.reportDisplaySize(flightId, width, height)`: report a
49
+ consumer's own displayed pixel size for a camera. The sidecar aggregates it
50
+ MAX-across-consumers to drive auto-resolution (distinct from `setRenderSize`,
51
+ which is the shared operator cap).
52
+ - `KerbcastClient.setTrackTarget(flightId, mode)`: ask a pan+zoom camera to
53
+ auto-track a moving vessel, or stop.
54
+ - `TrackMode` type (`"none" | "activeVessel" | "target"`).
55
+ - `trackMode?: TrackMode` on `CameraState` (WS push) and `CameraInfo`
56
+ (`GET /cameras`), server-authoritative so every consumer reflects the same
57
+ tracking state.
58
+ - `MockSidecar` accepts the new `report-display-size` and `set-track-target`
59
+ client messages (records them; `report-display-size` is advisory and does not
60
+ mutate render dims).
61
+ - `MockCameraInit` gains `kind`, `crewLocation`, `kerbalPersistentId`, and
62
+ `trackMode` for building crew and tracking fixtures.
63
+
3
64
  ## 0.12.0 - 2026-06-07
4
65
 
5
66
  ### 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,
@@ -196,6 +260,17 @@ export interface SetDegradePayload {
196
260
  */
197
261
  level: number;
198
262
  }
263
+ /**
264
+ * Forces a camera to render at the operator ceiling regardless of
265
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a feed
266
+ * stays forced while any connected viewer forces it, and releases when
267
+ * the last one clears it (or disconnects). Still yields to the adaptive
268
+ * framerate shed; overrides only the demand term.
269
+ */
270
+ export interface SetForceFullResolutionPayload {
271
+ flightId: number;
272
+ force: boolean;
273
+ }
199
274
  export interface SetFovPayload {
200
275
  flightId: number;
201
276
  fov: number;
@@ -248,6 +323,21 @@ export interface SetThrottleMainScreenPayload {
248
323
  */
249
324
  enabled: boolean;
250
325
  }
326
+ /**
327
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Mirrors
328
+ * `SetRenderSizePayload` in shape. Server-authoritative: the sidecar holds the
329
+ * chosen mode per camera and publishes it in `CameraState.track_mode`, so
330
+ * every browser reflects the same tracking state (never optimistic-local).
331
+ *
332
+ * Precedence note (for operators): a browser track OVERRIDES a kOS aim on the
333
+ * same camera; set `mode: none` to hand aiming back to kOS. While tracking, the
334
+ * camera also AUTO-ZOOMS to keep the target framed (distance-driven), owning the
335
+ * zoom over any manual FoV; `mode: none` restores manual zoom untouched.
336
+ */
337
+ export interface SetTrackTargetPayload {
338
+ flightId: number;
339
+ mode: TrackMode;
340
+ }
251
341
  export interface SetZoomRatePayload {
252
342
  flightId: number;
253
343
  /**
@@ -324,13 +414,50 @@ export type ClientMessage =
324
414
  content: SetLayersPayload;
325
415
  }
326
416
  /**
327
- * Override the operator render size for one camera. Even pixels
328
- * only (H.264 chroma); server caps at the ring's allocated max.
417
+ * Cap the auto-resolution for one camera. As of Stage 6 this is a
418
+ * CEILING on the demand-driven size (effective = min(auto max-across-
419
+ * consumers, this cap)), NOT an authoritative set: it cannot force a
420
+ * camera larger than current consumers report, only smaller. Even
421
+ * pixels only (H.264 chroma); server caps at the ring's allocated max.
329
422
  */
330
423
  | {
331
424
  type: "set-render-size";
332
425
  content: SetRenderSizePayload;
333
426
  }
427
+ /**
428
+ * Auto-track a moving vessel with a pan+zoom camera (or stop). The sidecar
429
+ * holds the chosen `mode` per camera and publishes it in
430
+ * `CameraState.track_mode`, so tracking state is server-authoritative and
431
+ * consistent across browsers. A browser track OVERRIDES a kOS aim on the
432
+ * same camera; send `mode: none` to hand aiming back to kOS.
433
+ */
434
+ | {
435
+ type: "set-track-target";
436
+ content: SetTrackTargetPayload;
437
+ }
438
+ /**
439
+ * A consumer reporting its OWN current display size in px; the sidecar
440
+ * aggregates MAX-across-consumers to drive auto-resolution
441
+ * (meet-the-minimum-need). Distinct from the operator `SetRenderSize`
442
+ * command: this is a per-consumer input, not a shared override — a
443
+ * departing consumer's size is cleared so the max relaxes.
444
+ */
445
+ | {
446
+ type: "report-display-size";
447
+ content: ReportDisplaySizePayload;
448
+ }
449
+ /**
450
+ * Force a camera to render at the operator ceiling regardless of
451
+ * display-size demand. Per-viewer, OR-aggregated by the sidecar: a
452
+ * feed stays forced while any connected viewer forces it, and
453
+ * releases when the last one clears it (or disconnects). Still
454
+ * yields to the adaptive framerate shed; overrides only the demand
455
+ * term, never the perf-protection ceiling.
456
+ */
457
+ | {
458
+ type: "set-force-full-resolution";
459
+ content: SetForceFullResolutionPayload;
460
+ }
334
461
  /**
335
462
  * Set the camera's field-of-view (degrees). Silently ignored for
336
463
  * 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,5 +1,6 @@
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
+ import { RecordingController, type RecordingControllerOptions } from "./recording";
3
4
  /** Controls the digital-static noise overlay baked into `cam.mediaStream`. */
4
5
  export interface NoiseConfig {
5
6
  /** When false, the pipeline is bypassed and `mediaStream` is the raw WebRTC track. */
@@ -22,6 +23,19 @@ export interface KerbcastCameraHandle {
22
23
  readonly stalled: boolean;
23
24
  /** Current {@link setShowStatic} setting. */
24
25
  readonly showStatic: boolean;
26
+ /**
27
+ * The feed's physical/ceiling maximum render size (`DiscoveredCamera.maxWidth`
28
+ * / `maxHeight`), or `null` until known. Populated from a prior
29
+ * `client.discover()` call (there is no live-pushed field for it), so a
30
+ * consumer that never calls `discover()` sees `null` here indefinitely.
31
+ * Used by `RecordingController` to recognise a force-full-resolution feed
32
+ * that has actually reached its ceiling, as opposed to one merely
33
+ * unchanged since before the force was sent.
34
+ */
35
+ readonly maxRenderSize: {
36
+ width: number;
37
+ height: number;
38
+ } | null;
25
39
  setLayers(layers: Layer[]): Promise<void>;
26
40
  setRenderSize(width: number, height: number): Promise<void>;
27
41
  setFov(fov: number): Promise<void>;
@@ -52,6 +66,15 @@ export interface KerbcastCameraHandle {
52
66
  * machinery keeps overriding it downward while throttled.
53
67
  */
54
68
  setQuality(preset: QualityPreset | null): Promise<void>;
69
+ /**
70
+ * Force this feed to render at the operator's configured maximum
71
+ * regardless of display size, until released by calling this with
72
+ * `false`. Per-viewer, OR-aggregated server-side across viewers: a feed
73
+ * stays forced while any viewer forces it, and releases when the last one
74
+ * clears it (or disconnects). Still yields to the adaptive framerate
75
+ * shed; it only overrides the display-size demand term.
76
+ */
77
+ setForceFullResolution(force: boolean): Promise<void>;
55
78
  requestKeyframe(): Promise<void>;
56
79
  /**
57
80
  * Override noise settings for this camera only. Takes precedence over the
@@ -121,6 +144,15 @@ export interface KerbcastClientConfig {
121
144
  sdp: string;
122
145
  cameras: number[];
123
146
  }>;
147
+ /**
148
+ * Options for the lazily-built {@link RecordingController} (`client.recording`).
149
+ * The load-bearing use is `loadTrimmer`: a consumer that can't rely on the
150
+ * default bare `import("mediabunny")` resolving (the sidecar's embedded web
151
+ * page serves its own copy of the package locally, offline/LAN, no CDN)
152
+ * supplies its own loader here. Bundler consumers (e.g. gonogo) can omit
153
+ * this and get the default.
154
+ */
155
+ recording?: RecordingControllerOptions;
124
156
  }
125
157
  /** WebRTC connection state surface. */
126
158
  export type KerbcastConnectionState = "disconnected" | "connecting" | "connected" | "failed";
@@ -328,6 +360,15 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
328
360
  private _warpRate;
329
361
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
330
362
  private _inFlight;
363
+ /** Client-side recording controller, lazily built on first `recording` access. */
364
+ private _recording;
365
+ /**
366
+ * Physical/ceiling max render size per camera, cached from the last
367
+ * `discover()` call. Empty until `discover()` has been called at least
368
+ * once; a camera absent from the last discovery response is simply
369
+ * missing here (its handle's `maxRenderSize` reads `null`).
370
+ */
371
+ private readonly maxRenderSizeByFlight;
331
372
  constructor(cfg: KerbcastClientConfig, transport?: KerbcastTransport);
332
373
  get state(): KerbcastConnectionState;
333
374
  get cameras(): ReadonlyArray<CameraState>;
@@ -352,6 +393,13 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
352
393
  epoch: number;
353
394
  warpRate: number;
354
395
  };
396
+ /**
397
+ * Client-side recording controller. Records a camera's already-received
398
+ * track with the browser's `MediaRecorder`, annotated with the mission-time
399
+ * (UT) clock; purely local, no sidecar involvement. Lazily created on first
400
+ * access and stable thereafter. See {@link RecordingController}.
401
+ */
402
+ get recording(): RecordingController;
355
403
  /**
356
404
  * Whether KSP is currently in a flight scene, from the sidecar's
357
405
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -371,6 +419,14 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
371
419
  * to subscribe to before opening a peer connection.
372
420
  */
373
421
  discover(): Promise<DiscoveredCamera[]>;
422
+ /**
423
+ * Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
424
+ * `discover()` has never been called or never reported this camera.
425
+ */
426
+ _maxRenderSize(flightId: number): {
427
+ width: number;
428
+ height: number;
429
+ } | null;
374
430
  /**
375
431
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
376
432
  * `connect` while already connected disconnects first.
@@ -409,6 +465,23 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
409
465
  * goes null once the sidecar confirms with a SlotMap.
410
466
  */
411
467
  unsubscribe(flightId: number): Promise<void>;
468
+ /**
469
+ * Report this consumer's own displayed pixel size for a camera, driving the
470
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
471
+ * sets the shared render size directly, last-writer-wins), this is a
472
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
473
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
474
+ * feed primitives self-measure and call this; consumers do not report by hand.
475
+ */
476
+ reportDisplaySize(flightId: number, width: number, height: number): Promise<void>;
477
+ /**
478
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
479
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
480
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
481
+ * rather than this call (two browsers stay in agreement). While tracking, the
482
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
483
+ */
484
+ setTrackTarget(flightId: number, mode: TrackMode): Promise<void>;
412
485
  /**
413
486
  * Internal — resolves the effective noise enabled state for a camera,
414
487
  * merging the per-camera override (if any) with the client-level default.
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CameraLifecycle, ErrorSource } from "./__generated__/types";
2
2
  import { tryCreateNoisePipeline } from "./noise";
3
+ import { RecordingController } from "./recording";
3
4
  /** Intensity the static runs at when a camera has no live source (signal lost). */
4
5
  const SOURCELESS_INTENSITY = 1.0;
5
6
  /** Floor applied to the degrade-driven intensity of a live feed. */
@@ -185,6 +186,9 @@ class CameraHandle extends TypedEmitter {
185
186
  get showStatic() {
186
187
  return this._showStatic;
187
188
  }
189
+ get maxRenderSize() {
190
+ return this.client._maxRenderSize(this.flightId);
191
+ }
188
192
  configure(options) {
189
193
  this._noiseOverride = options.noise ?? null;
190
194
  // Re-evaluate with the new noise setting: rebuild from whatever source
@@ -390,6 +394,12 @@ class CameraHandle extends TypedEmitter {
390
394
  content: { flightId: this.flightId, preset: preset ?? undefined },
391
395
  });
392
396
  }
397
+ async setForceFullResolution(force) {
398
+ await this.client._send({
399
+ type: "set-force-full-resolution",
400
+ content: { flightId: this.flightId, force },
401
+ });
402
+ }
393
403
  async requestKeyframe() {
394
404
  await this.client._send({
395
405
  type: "request-keyframe",
@@ -465,6 +475,15 @@ export class KerbcastClient extends TypedEmitter {
465
475
  this._warpRate = 1;
466
476
  /** Whether KSP is in a flight scene. `undefined` until the first signal. */
467
477
  this._inFlight = undefined;
478
+ /** Client-side recording controller, lazily built on first `recording` access. */
479
+ this._recording = null;
480
+ /**
481
+ * Physical/ceiling max render size per camera, cached from the last
482
+ * `discover()` call. Empty until `discover()` has been called at least
483
+ * once; a camera absent from the last discovery response is simply
484
+ * missing here (its handle's `maxRenderSize` reads `null`).
485
+ */
486
+ this.maxRenderSizeByFlight = new Map();
468
487
  this.cfg = cfg;
469
488
  this.transport =
470
489
  transport ??
@@ -507,6 +526,17 @@ export class KerbcastClient extends TypedEmitter {
507
526
  warpRate: this._warpRate,
508
527
  };
509
528
  }
529
+ /**
530
+ * Client-side recording controller. Records a camera's already-received
531
+ * track with the browser's `MediaRecorder`, annotated with the mission-time
532
+ * (UT) clock; purely local, no sidecar involvement. Lazily created on first
533
+ * access and stable thereafter. See {@link RecordingController}.
534
+ */
535
+ get recording() {
536
+ if (!this._recording)
537
+ this._recording = new RecordingController(this, this.cfg.recording);
538
+ return this._recording;
539
+ }
510
540
  /**
511
541
  * Whether KSP is currently in a flight scene, from the sidecar's
512
542
  * `scene-state-changed`. `undefined` until the first signal (so a
@@ -537,8 +567,21 @@ export class KerbcastClient extends TypedEmitter {
537
567
  if (!res.ok)
538
568
  throw new Error(`/cameras returned ${res.status}`);
539
569
  const body = (await res.json());
570
+ for (const cam of body.cameras) {
571
+ this.maxRenderSizeByFlight.set(cam.flightId, {
572
+ width: cam.maxWidth,
573
+ height: cam.maxHeight,
574
+ });
575
+ }
540
576
  return body.cameras;
541
577
  }
578
+ /**
579
+ * Internal: backs {@link KerbcastCameraHandle.maxRenderSize}. `null` when
580
+ * `discover()` has never been called or never reported this camera.
581
+ */
582
+ _maxRenderSize(flightId) {
583
+ return this.maxRenderSizeByFlight.get(flightId) ?? null;
584
+ }
542
585
  /**
543
586
  * Open the WebRTC peer and subscribe to cameras. Idempotent: calling
544
587
  * `connect` while already connected disconnects first.
@@ -658,6 +701,27 @@ export class KerbcastClient extends TypedEmitter {
658
701
  async unsubscribe(flightId) {
659
702
  await this._send({ type: "unsubscribe", content: { flightId } });
660
703
  }
704
+ /**
705
+ * Report this consumer's own displayed pixel size for a camera, driving the
706
+ * sidecar's auto-resolution. Unlike `setRenderSize` (an operator command that
707
+ * sets the shared render size directly, last-writer-wins), this is a
708
+ * per-consumer input the sidecar aggregates MAX-across-consumers, so a big
709
+ * spotlight bumps the stream up while tiny avatars leave it small. The SDK's
710
+ * feed primitives self-measure and call this; consumers do not report by hand.
711
+ */
712
+ async reportDisplaySize(flightId, width, height) {
713
+ await this._send({ type: "report-display-size", content: { flightId, width, height } });
714
+ }
715
+ /**
716
+ * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
717
+ * intent; the sidecar holds the chosen mode per camera and publishes it in
718
+ * `CameraState.trackMode`, so the UI must reflect that server-confirmed value
719
+ * rather than this call (two browsers stay in agreement). While tracking, the
720
+ * camera also auto-zooms; `TrackMode.None` hands aiming/zoom back.
721
+ */
722
+ async setTrackTarget(flightId, mode) {
723
+ await this._send({ type: "set-track-target", content: { flightId, mode } });
724
+ }
661
725
  /**
662
726
  * Internal — resolves the effective noise enabled state for a camera,
663
727
  * merging the per-camera override (if any) with the client-level default.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./__generated__/types";
2
2
  export { KerbcastClient, BrowserKerbcastTransport, type BrowserKerbcastTransportOptions, type InboundVideoStats, type KerbcastCameraHandle, type KerbcastClientConfig, type KerbcastConnectionState, type KerbcastClientEvents, type KerbcastCameraEvents, type KerbcastTransport, type KerbcastPeer, type KerbcastDataChannel, type DiscoveredCamera, type NoiseConfig, } from "./client";
3
3
  export { PanZoomController, type PanZoomCommandSink, type PanZoomBounds, type PanZoomControllerOptions, } from "./panZoom";
4
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, type ActiveRecordingInfo, type RecordingClient, type RecordingControllerOptions, type RecordingHandle, type RecordingsSnapshot, type GroupedRecordingHandle, type GroupedRecordingStart, type GroupTrimmer, type TrimmerLoader, type UtSample, type StartRecordingOptions, type StopGroupedRecordingOptions, } from "./recording";
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@
5
5
  export * from "./__generated__/types";
6
6
  export { KerbcastClient, BrowserKerbcastTransport, } from "./client";
7
7
  export { PanZoomController, } from "./panZoom";
8
+ export { RecordingController, createMediabunnyTrimmerLoader, negotiateMimeType, commonUtWindow, utToRecordingTimeMs, DEFAULT_COVERAGE_TIMEOUT_MS, ARM_TIMEOUT_MS, } from "./recording";