@ksp-gonogo/kerbcast 1.8.1 → 1.9.1-rc.1

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.1-rc.1 - 2026-08-06
4
+
5
+ <!-- TODO: fill in. -->
6
+
7
+
3
8
  ## 1.8.0 - 2026-07-28
4
9
 
5
10
  Client-side camera recording. The capture itself is pure SDK: the recorder taps
@@ -22,6 +22,35 @@ export declare enum CameraKind {
22
22
  Part = "part",
23
23
  Kerbal = "kerbal"
24
24
  }
25
+ /**
26
+ * How hard a camera is currently being captured. Reported per camera in
27
+ * `CameraState` so a consumer renders what is actually happening rather than
28
+ * predicting it from delay arithmetic.
29
+ *
30
+ * `Background` and `Off` only occur when background capture is enabled; with it off
31
+ * (the default) a subscribed camera is `Full` and an unsubscribed one is `Off`,
32
+ * which is exactly today's behaviour under the new names.
33
+ */
34
+ export declare enum CaptureState {
35
+ /** Capturing at the primary rate: an ordinary live feed. */
36
+ Full = "full",
37
+ /**
38
+ * Promoted and on the way to the primary rate, but not there yet — either
39
+ * awaiting the forced keyframe or still rate-limited by the capture budget.
40
+ */
41
+ Ramping = "ramping",
42
+ /**
43
+ * Background capture: recent but sparse. A consumer should present this as
44
+ * a reduced-rate channel, not as a live feed.
45
+ */
46
+ Background = "background",
47
+ /**
48
+ * Not being captured at all — unsubscribed, or the background layer has
49
+ * been shed under load. A consumer must NOT keep presenting the last frame
50
+ * as though it were a live low-rate channel.
51
+ */
52
+ Off = "off"
53
+ }
25
54
  /**
26
55
  * Which source a kerbal camera is currently rendering: a seated IVA
27
56
  * portrait, or the kerbal's own view while on EVA. Only meaningful when
@@ -95,6 +124,11 @@ export interface CameraState {
95
124
  * Defaults to `Part` so existing payloads are unaffected.
96
125
  */
97
126
  kind?: CameraKind;
127
+ /**
128
+ * How hard this camera is currently being captured. Defaults to `Full` so
129
+ * existing payloads and plugins predating background capture are unaffected.
130
+ */
131
+ captureState?: CaptureState;
98
132
  /**
99
133
  * The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
100
134
  * consumer correlates against. `None` for part cameras. Distinct from
@@ -248,6 +282,18 @@ export interface ReportDisplaySizePayload {
248
282
  export interface SceneStateChangedPayload {
249
283
  inFlight: boolean;
250
284
  }
285
+ /**
286
+ * Consumer's background-capture request. Global rather than
287
+ * per-camera: it is a statement about how much background cost this consumer
288
+ * wants spent, not about one camera. Zero stops asking.
289
+ */
290
+ export interface SetBackgroundCaptureFpsPayload {
291
+ /**
292
+ * Requested rate in fps for cameras this consumer is not displaying.
293
+ * Clamped to the operator ceiling; 0 means no background capture.
294
+ */
295
+ fps: number;
296
+ }
251
297
  export interface SetDegradePayload {
252
298
  flightId: number;
253
299
  /**
@@ -458,6 +504,31 @@ export type ClientMessage =
458
504
  type: "set-force-full-resolution";
459
505
  content: SetForceFullResolutionPayload;
460
506
  }
507
+ /**
508
+ * Ask for background capture: subscribed cameras that this
509
+ * consumer is NOT displaying keep capturing at a low rate instead of
510
+ * stopping, so switching to one promotes an already-flowing stream rather
511
+ * than cold-starting from black.
512
+ *
513
+ * Per-consumer and MAX-aggregated, like `ReportDisplaySize`: it runs
514
+ * at the highest rate any connected consumer asked for, and relaxes when
515
+ * they lower it or disconnect. A consumer that never sends this pays
516
+ * nothing, which is why a stock install needs no configuration to serve
517
+ * both a plain viewer and a consumer that wants background capture.
518
+ *
519
+ * The rate is a REQUEST, not a guarantee. It is clamped to the operator's
520
+ * `BackgroundCaptureFps` ceiling and is the first thing dropped when the
521
+ * game approaches its framerate floor — watch `CameraState.capture_state`
522
+ * for what is actually happening. Send 0 to stop asking.
523
+ *
524
+ * Receiving the frames is not the same as keeping them: a consumer that
525
+ * wants history for a camera it isn't showing has to buffer the background
526
+ * feed itself.
527
+ */
528
+ | {
529
+ type: "set-background-capture-fps";
530
+ content: SetBackgroundCaptureFpsPayload;
531
+ }
461
532
  /**
462
533
  * Set the camera's field-of-view (degrees). Silently ignored for
463
534
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
@@ -22,6 +22,36 @@ export var CameraKind;
22
22
  CameraKind["Part"] = "part";
23
23
  CameraKind["Kerbal"] = "kerbal";
24
24
  })(CameraKind || (CameraKind = {}));
25
+ /**
26
+ * How hard a camera is currently being captured. Reported per camera in
27
+ * `CameraState` so a consumer renders what is actually happening rather than
28
+ * predicting it from delay arithmetic.
29
+ *
30
+ * `Background` and `Off` only occur when background capture is enabled; with it off
31
+ * (the default) a subscribed camera is `Full` and an unsubscribed one is `Off`,
32
+ * which is exactly today's behaviour under the new names.
33
+ */
34
+ export var CaptureState;
35
+ (function (CaptureState) {
36
+ /** Capturing at the primary rate: an ordinary live feed. */
37
+ CaptureState["Full"] = "full";
38
+ /**
39
+ * Promoted and on the way to the primary rate, but not there yet — either
40
+ * awaiting the forced keyframe or still rate-limited by the capture budget.
41
+ */
42
+ CaptureState["Ramping"] = "ramping";
43
+ /**
44
+ * Background capture: recent but sparse. A consumer should present this as
45
+ * a reduced-rate channel, not as a live feed.
46
+ */
47
+ CaptureState["Background"] = "background";
48
+ /**
49
+ * Not being captured at all — unsubscribed, or the background layer has
50
+ * been shed under load. A consumer must NOT keep presenting the last frame
51
+ * as though it were a live low-rate channel.
52
+ */
53
+ CaptureState["Off"] = "off";
54
+ })(CaptureState || (CaptureState = {}));
25
55
  /**
26
56
  * Which source a kerbal camera is currently rendering: a seated IVA
27
57
  * portrait, or the kerbal's own view while on EVA. Only meaningful when
package/dist/client.d.ts CHANGED
@@ -474,6 +474,31 @@ export declare class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
474
474
  * feed primitives self-measure and call this; consumers do not report by hand.
475
475
  */
476
476
  reportDisplaySize(flightId: number, width: number, height: number): Promise<void>;
477
+ /**
478
+ * Ask for background capture: subscribed cameras this consumer is NOT
479
+ * displaying keep capturing at a low rate instead of stopping, so switching
480
+ * to one promotes an already-flowing stream rather than cold-starting from
481
+ * black.
482
+ *
483
+ * Opt-in and per-consumer, aggregated MAX-across-consumers. A consumer that
484
+ * never calls this pays nothing, which is why the same kerbcast install
485
+ * serves both a plain viewer and a consumer that wants background capture
486
+ * with no configuration either side. Pass 0 to stop asking; the request is
487
+ * dropped automatically on disconnect.
488
+ *
489
+ * The rate is a REQUEST, not a guarantee: it is capped by the operator's
490
+ * `BackgroundCaptureFps` ceiling and is the first thing dropped as the game
491
+ * approaches its framerate floor. Read `CameraState.captureState` for what is
492
+ * actually happening rather than assuming this took effect — it reports
493
+ * `Background` only while the reduced rate is genuinely running, and `Off`
494
+ * once the layer has been shed.
495
+ *
496
+ * Receiving background frames is not the same as keeping them. A consumer
497
+ * that wants *history* for a camera it isn't showing — to render instantly on
498
+ * switch under a simulated signal delay, say — has to buffer those feeds
499
+ * itself; background capture only makes the frames available to buffer.
500
+ */
501
+ setBackgroundCaptureFps(fps: number): Promise<void>;
477
502
  /**
478
503
  * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
479
504
  * intent; the sidecar holds the chosen mode per camera and publishes it in
package/dist/client.js CHANGED
@@ -712,6 +712,33 @@ export class KerbcastClient extends TypedEmitter {
712
712
  async reportDisplaySize(flightId, width, height) {
713
713
  await this._send({ type: "report-display-size", content: { flightId, width, height } });
714
714
  }
715
+ /**
716
+ * Ask for background capture: subscribed cameras this consumer is NOT
717
+ * displaying keep capturing at a low rate instead of stopping, so switching
718
+ * to one promotes an already-flowing stream rather than cold-starting from
719
+ * black.
720
+ *
721
+ * Opt-in and per-consumer, aggregated MAX-across-consumers. A consumer that
722
+ * never calls this pays nothing, which is why the same kerbcast install
723
+ * serves both a plain viewer and a consumer that wants background capture
724
+ * with no configuration either side. Pass 0 to stop asking; the request is
725
+ * dropped automatically on disconnect.
726
+ *
727
+ * The rate is a REQUEST, not a guarantee: it is capped by the operator's
728
+ * `BackgroundCaptureFps` ceiling and is the first thing dropped as the game
729
+ * approaches its framerate floor. Read `CameraState.captureState` for what is
730
+ * actually happening rather than assuming this took effect — it reports
731
+ * `Background` only while the reduced rate is genuinely running, and `Off`
732
+ * once the layer has been shed.
733
+ *
734
+ * Receiving background frames is not the same as keeping them. A consumer
735
+ * that wants *history* for a camera it isn't showing — to render instantly on
736
+ * switch under a simulated signal delay, say — has to buffer those feeds
737
+ * itself; background capture only makes the frames available to buffer.
738
+ */
739
+ async setBackgroundCaptureFps(fps) {
740
+ await this._send({ type: "set-background-capture-fps", content: { fps } });
741
+ }
715
742
  /**
716
743
  * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
717
744
  * intent; the sidecar holds the chosen mode per camera and publishes it in
package/dist/panZoom.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -74,6 +74,31 @@ export interface PanZoomControllerOptions {
74
74
  * Default: 0.05.
75
75
  */
76
76
  analogDeadzone?: number;
77
+ /**
78
+ * How close an echo has to get to an outstanding setpoint (degrees) before
79
+ * the camera counts as having arrived. Wider than the plugin's own 0.01
80
+ * degree reporting threshold so a rounding difference does not hold the
81
+ * setpoint open.
82
+ * Default: 0.5.
83
+ */
84
+ settleEpsilonDeg?: number;
85
+ /**
86
+ * How many consecutive *moving* echoes may fail to close on an outstanding
87
+ * setpoint before the controller gives up and adopts what the camera
88
+ * reports. Catches something else owning the camera (another operator, or
89
+ * auto-track) without waiting out the full timeout.
90
+ * Default: 2.
91
+ */
92
+ settleStallEchoes?: number;
93
+ /**
94
+ * Hard cap (ms) on how long a setpoint is held against echoes. Backstop for
95
+ * a camera that parks short of the setpoint and then reports the same
96
+ * position forever (a bounds mismatch would do it), which no
97
+ * progress-based rule can detect. Sized for the worst real case: 360
98
+ * degrees of yaw at 90 deg/s plus the plugin's ~1Hz status write.
99
+ * Default: 6000.
100
+ */
101
+ settleTimeoutMs?: number;
77
102
  }
78
103
  /**
79
104
  * Headless pan/zoom state machine. Manages rate deduplication, analog
@@ -87,10 +112,15 @@ export declare class PanZoomController {
87
112
  private readonly fovNudgeDeg;
88
113
  private readonly fovSliderDebounceMs;
89
114
  private readonly analogDeadzone;
115
+ private readonly settleEpsilonDeg;
116
+ private readonly settleStallEchoes;
117
+ private readonly settleTimeoutMs;
90
118
  private sentPanRate;
91
119
  private sentZoomRate;
92
120
  private localPan;
93
121
  private localFov;
122
+ private panSetpoint;
123
+ private fovSetpoint;
94
124
  private _sliderFov;
95
125
  private sliderDragging;
96
126
  private fovDebounceTimer;
@@ -112,6 +142,12 @@ export declare class PanZoomController {
112
142
  * Applies the echoed FoV to the accumulator and slider only when the
113
143
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
114
144
  * debounced send). Mirrors CameraFeed.tsx:370-387.
145
+ *
146
+ * An echo is also held off while an absolute setpoint we sent is still
147
+ * outstanding: the plugin slews toward a setpoint and reports its position
148
+ * at ~1Hz, so echoes in that window carry positions the camera has already
149
+ * left, and adopting one would re-base the next nudge behind where the
150
+ * camera is going.
115
151
  */
116
152
  syncFromState(state: {
117
153
  fov: number;
@@ -186,5 +222,8 @@ export declare class PanZoomController {
186
222
  */
187
223
  dispose(): void;
188
224
  private flushFovSlider;
225
+ private panEchoWins;
226
+ private fovEchoWins;
227
+ private settled;
189
228
  private setSliderFovInternal;
190
229
  }
package/dist/panZoom.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -40,6 +40,9 @@ function clamp(v, lo, hi) {
40
40
  function applyDeadzone(v, deadzone) {
41
41
  return Math.abs(v) < deadzone ? 0 : v;
42
42
  }
43
+ function newSettling() {
44
+ return { lastDistance: null, stalls: 0, issuedAt: Date.now() };
45
+ }
43
46
  // ---------------------------------------------------------------------------
44
47
  // PanZoomController
45
48
  // ---------------------------------------------------------------------------
@@ -57,6 +60,14 @@ export class PanZoomController {
57
60
  // Optimistic accumulators for discrete nudges.
58
61
  this.localPan = { yaw: 0, pitch: 0 };
59
62
  this.localFov = 0;
63
+ /*
64
+ Outstanding absolute setpoints. Non-null from the moment a setPan / setFov
65
+ goes out until an echo shows the camera got there (or one of `settled`'s
66
+ give-up rules fires). While one is outstanding the accumulator holds the
67
+ setpoint and echoes do not touch it.
68
+ */
69
+ this.panSetpoint = null;
70
+ this.fovSetpoint = null;
60
71
  // FoV slider state.
61
72
  this._sliderFov = 0;
62
73
  this.sliderDragging = false;
@@ -80,6 +91,9 @@ export class PanZoomController {
80
91
  this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
81
92
  this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
82
93
  this.analogDeadzone = opts.analogDeadzone ?? 0.05;
94
+ this.settleEpsilonDeg = opts.settleEpsilonDeg ?? 0.5;
95
+ this.settleStallEchoes = opts.settleStallEchoes ?? 2;
96
+ this.settleTimeoutMs = opts.settleTimeoutMs ?? 6000;
83
97
  }
84
98
  // ---------------------------------------------------------------------------
85
99
  // Public getters
@@ -102,6 +116,12 @@ export class PanZoomController {
102
116
  * Applies the echoed FoV to the accumulator and slider only when the
103
117
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
104
118
  * debounced send). Mirrors CameraFeed.tsx:370-387.
119
+ *
120
+ * An echo is also held off while an absolute setpoint we sent is still
121
+ * outstanding: the plugin slews toward a setpoint and reports its position
122
+ * at ~1Hz, so echoes in that window carry positions the camera has already
123
+ * left, and adopting one would re-base the next nudge behind where the
124
+ * camera is going.
105
125
  */
106
126
  syncFromState(state) {
107
127
  this.bounds = {
@@ -115,13 +135,13 @@ export class PanZoomController {
115
135
  const panIdle = !this.ballDragging &&
116
136
  this.sentPanRate.yaw === 0 &&
117
137
  this.sentPanRate.pitch === 0;
118
- if (panIdle) {
138
+ if (panIdle && this.panEchoWins(state.panYaw, state.panPitch)) {
119
139
  this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
120
140
  }
121
141
  const zoomIdle = this.sentZoomRate === 0 &&
122
142
  !this.sliderDragging &&
123
143
  this.pendingFov === null;
124
- if (zoomIdle) {
144
+ if (zoomIdle && this.fovEchoWins(state.fov)) {
125
145
  this.localFov = state.fov;
126
146
  this.setSliderFovInternal(state.fov);
127
147
  }
@@ -140,6 +160,9 @@ export class PanZoomController {
140
160
  if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch)
141
161
  return;
142
162
  this.sentPanRate = { yaw: y, pitch: p };
163
+ // A rate supersedes any absolute we were still waiting on, including the
164
+ // zero that ends the hold: the camera is wherever the rate left it.
165
+ this.panSetpoint = null;
143
166
  void this.sink.setPanRate(y, p);
144
167
  }
145
168
  /**
@@ -166,6 +189,7 @@ export class PanZoomController {
166
189
  if (r === this.sentZoomRate)
167
190
  return;
168
191
  this.sentZoomRate = r;
192
+ this.fovSetpoint = null; // same reasoning as setPanRate
169
193
  void this.sink.setZoomRate(r);
170
194
  }
171
195
  // ---------------------------------------------------------------------------
@@ -180,6 +204,11 @@ export class PanZoomController {
180
204
  const b = this.bounds;
181
205
  this.localPan.yaw = clamp(this.localPan.yaw + yawSign * this.panNudgeDeg, b.panYawMin, b.panYawMax);
182
206
  this.localPan.pitch = clamp(this.localPan.pitch + pitchSign * this.panNudgeDeg, b.panPitchMin, b.panPitchMax);
207
+ this.panSetpoint = {
208
+ yaw: this.localPan.yaw,
209
+ pitch: this.localPan.pitch,
210
+ ...newSettling(),
211
+ };
183
212
  void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
184
213
  }
185
214
  /**
@@ -191,6 +220,7 @@ export class PanZoomController {
191
220
  const b = this.bounds;
192
221
  const next = clamp(this.localFov + deltaSign * this.fovNudgeDeg, b.fovMin, b.fovMax);
193
222
  this.localFov = next;
223
+ this.fovSetpoint = { fov: next, ...newSettling() };
194
224
  void this.sink.setFov(next);
195
225
  }
196
226
  // ---------------------------------------------------------------------------
@@ -244,6 +274,9 @@ export class PanZoomController {
244
274
  */
245
275
  setBallDragging(dragging) {
246
276
  this.ballDragging = dragging;
277
+ // Taking the ball abandons any absolute we were waiting on.
278
+ if (dragging)
279
+ this.panSetpoint = null;
247
280
  }
248
281
  // ---------------------------------------------------------------------------
249
282
  // Lifecycle
@@ -265,6 +298,9 @@ export class PanZoomController {
265
298
  }
266
299
  this.ballDragging = false;
267
300
  this.sliderDragging = false;
301
+ // Nothing is being commanded any more, so echoes win again.
302
+ this.panSetpoint = null;
303
+ this.fovSetpoint = null;
268
304
  if (this.fovDebounceTimer !== null) {
269
305
  clearTimeout(this.fovDebounceTimer);
270
306
  this.fovDebounceTimer = null;
@@ -291,9 +327,60 @@ export class PanZoomController {
291
327
  const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
292
328
  this.localFov = fov;
293
329
  this.pendingFov = null;
330
+ this.fovSetpoint = { fov, ...newSettling() };
294
331
  void this.sink.setFov(fov);
295
332
  }
296
333
  }
334
+ /*
335
+ Whether an echoed pan should be adopted into the accumulator. True when
336
+ nothing is outstanding, or when the outstanding setpoint has been settled
337
+ one way or another (in which case it is cleared and the camera's own
338
+ reading wins from here).
339
+ */
340
+ panEchoWins(yaw, pitch) {
341
+ const sp = this.panSetpoint;
342
+ if (sp === null)
343
+ return true;
344
+ const distance = Math.max(Math.abs(yaw - sp.yaw), Math.abs(pitch - sp.pitch));
345
+ if (!this.settled(sp, distance))
346
+ return false;
347
+ this.panSetpoint = null;
348
+ return true;
349
+ }
350
+ /* `panEchoWins` for FoV. */
351
+ fovEchoWins(fov) {
352
+ const sp = this.fovSetpoint;
353
+ if (sp === null)
354
+ return true;
355
+ if (!this.settled(sp, Math.abs(fov - sp.fov)))
356
+ return false;
357
+ this.fovSetpoint = null;
358
+ return true;
359
+ }
360
+ /*
361
+ Give up on an outstanding setpoint when the camera arrived, when it is
362
+ moving but no longer closing on it (something else owns the camera: another
363
+ operator, or auto-track), or when the timeout backstop expires (it parked
364
+ short and is reporting the same position forever, which no progress-based
365
+ rule can see). Mutates the settling bookkeeping.
366
+ */
367
+ settled(sp, distance) {
368
+ const eps = this.settleEpsilonDeg;
369
+ if (distance <= eps)
370
+ return true;
371
+ if (Date.now() - sp.issuedAt >= this.settleTimeoutMs)
372
+ return true;
373
+ const prev = sp.lastDistance;
374
+ sp.lastDistance = distance;
375
+ if (prev === null)
376
+ return false;
377
+ const moved = Math.abs(prev - distance) > eps;
378
+ const closing = distance < prev - eps;
379
+ if (!moved)
380
+ return false;
381
+ sp.stalls = closing ? 0 : sp.stalls + 1;
382
+ return sp.stalls >= this.settleStallEchoes;
383
+ }
297
384
  setSliderFovInternal(fov) {
298
385
  this._sliderFov = fov;
299
386
  for (const cb of this.sliderFovListeners)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast",
3
- "version": "1.8.1",
3
+ "version": "1.9.1-rc.1",
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",
@@ -29,6 +29,36 @@ export enum CameraKind {
29
29
  Kerbal = "kerbal",
30
30
  }
31
31
 
32
+ /**
33
+ * How hard a camera is currently being captured. Reported per camera in
34
+ * `CameraState` so a consumer renders what is actually happening rather than
35
+ * predicting it from delay arithmetic.
36
+ *
37
+ * `Background` and `Off` only occur when background capture is enabled; with it off
38
+ * (the default) a subscribed camera is `Full` and an unsubscribed one is `Off`,
39
+ * which is exactly today's behaviour under the new names.
40
+ */
41
+ export enum CaptureState {
42
+ /** Capturing at the primary rate: an ordinary live feed. */
43
+ Full = "full",
44
+ /**
45
+ * Promoted and on the way to the primary rate, but not there yet — either
46
+ * awaiting the forced keyframe or still rate-limited by the capture budget.
47
+ */
48
+ Ramping = "ramping",
49
+ /**
50
+ * Background capture: recent but sparse. A consumer should present this as
51
+ * a reduced-rate channel, not as a live feed.
52
+ */
53
+ Background = "background",
54
+ /**
55
+ * Not being captured at all — unsubscribed, or the background layer has
56
+ * been shed under load. A consumer must NOT keep presenting the last frame
57
+ * as though it were a live low-rate channel.
58
+ */
59
+ Off = "off",
60
+ }
61
+
32
62
  /**
33
63
  * Which source a kerbal camera is currently rendering: a seated IVA
34
64
  * portrait, or the kerbal's own view while on EVA. Only meaningful when
@@ -106,6 +136,11 @@ export interface CameraState {
106
136
  * Defaults to `Part` so existing payloads are unaffected.
107
137
  */
108
138
  kind?: CameraKind;
139
+ /**
140
+ * How hard this camera is currently being captured. Defaults to `Full` so
141
+ * existing payloads and plugins predating background capture are unaffected.
142
+ */
143
+ captureState?: CaptureState;
109
144
  /**
110
145
  * The kerbal's real `ProtoCrewMember.persistentID`: the stable key a
111
146
  * consumer correlates against. `None` for part cameras. Distinct from
@@ -268,6 +303,19 @@ export interface SceneStateChangedPayload {
268
303
  inFlight: boolean;
269
304
  }
270
305
 
306
+ /**
307
+ * Consumer's background-capture request. Global rather than
308
+ * per-camera: it is a statement about how much background cost this consumer
309
+ * wants spent, not about one camera. Zero stops asking.
310
+ */
311
+ export interface SetBackgroundCaptureFpsPayload {
312
+ /**
313
+ * Requested rate in fps for cameras this consumer is not displaying.
314
+ * Clamped to the operator ceiling; 0 means no background capture.
315
+ */
316
+ fps: number;
317
+ }
318
+
271
319
  export interface SetDegradePayload {
272
320
  flightId: number;
273
321
  /**
@@ -473,6 +521,28 @@ export type ClientMessage =
473
521
  * term, never the perf-protection ceiling.
474
522
  */
475
523
  | { type: "set-force-full-resolution", content: SetForceFullResolutionPayload }
524
+ /**
525
+ * Ask for background capture: subscribed cameras that this
526
+ * consumer is NOT displaying keep capturing at a low rate instead of
527
+ * stopping, so switching to one promotes an already-flowing stream rather
528
+ * than cold-starting from black.
529
+ *
530
+ * Per-consumer and MAX-aggregated, like `ReportDisplaySize`: it runs
531
+ * at the highest rate any connected consumer asked for, and relaxes when
532
+ * they lower it or disconnect. A consumer that never sends this pays
533
+ * nothing, which is why a stock install needs no configuration to serve
534
+ * both a plain viewer and a consumer that wants background capture.
535
+ *
536
+ * The rate is a REQUEST, not a guarantee. It is clamped to the operator's
537
+ * `BackgroundCaptureFps` ceiling and is the first thing dropped when the
538
+ * game approaches its framerate floor — watch `CameraState.capture_state`
539
+ * for what is actually happening. Send 0 to stop asking.
540
+ *
541
+ * Receiving the frames is not the same as keeping them: a consumer that
542
+ * wants history for a camera it isn't showing has to buffer the background
543
+ * feed itself.
544
+ */
545
+ | { type: "set-background-capture-fps", content: SetBackgroundCaptureFpsPayload }
476
546
  /**
477
547
  * Set the camera's field-of-view (degrees). Silently ignored for
478
548
  * parts whose Hullcam module is the fixed base (`supportsZoom ==
package/src/client.ts CHANGED
@@ -1127,6 +1127,34 @@ export class KerbcastClient extends TypedEmitter<KerbcastClientEvents> {
1127
1127
  await this._send({ type: "report-display-size", content: { flightId, width, height } });
1128
1128
  }
1129
1129
 
1130
+ /**
1131
+ * Ask for background capture: subscribed cameras this consumer is NOT
1132
+ * displaying keep capturing at a low rate instead of stopping, so switching
1133
+ * to one promotes an already-flowing stream rather than cold-starting from
1134
+ * black.
1135
+ *
1136
+ * Opt-in and per-consumer, aggregated MAX-across-consumers. A consumer that
1137
+ * never calls this pays nothing, which is why the same kerbcast install
1138
+ * serves both a plain viewer and a consumer that wants background capture
1139
+ * with no configuration either side. Pass 0 to stop asking; the request is
1140
+ * dropped automatically on disconnect.
1141
+ *
1142
+ * The rate is a REQUEST, not a guarantee: it is capped by the operator's
1143
+ * `BackgroundCaptureFps` ceiling and is the first thing dropped as the game
1144
+ * approaches its framerate floor. Read `CameraState.captureState` for what is
1145
+ * actually happening rather than assuming this took effect — it reports
1146
+ * `Background` only while the reduced rate is genuinely running, and `Off`
1147
+ * once the layer has been shed.
1148
+ *
1149
+ * Receiving background frames is not the same as keeping them. A consumer
1150
+ * that wants *history* for a camera it isn't showing — to render instantly on
1151
+ * switch under a simulated signal delay, say — has to buffer those feeds
1152
+ * itself; background capture only makes the frames available to buffer.
1153
+ */
1154
+ async setBackgroundCaptureFps(fps: number): Promise<void> {
1155
+ await this._send({ type: "set-background-capture-fps", content: { fps } });
1156
+ }
1157
+
1130
1158
  /**
1131
1159
  * Ask a pan+zoom camera to auto-track a moving vessel (or stop). Sends the
1132
1160
  * intent; the sidecar holds the chosen mode per camera and publishes it in
@@ -386,6 +386,217 @@ describe("PanZoomController: nudge after echo-sync", () => {
386
386
  });
387
387
  });
388
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // Outstanding setpoints: an echo captured mid-slew must not re-base the
391
+ // accumulator behind where the camera is going. The plugin slews toward an
392
+ // absolute at 90 deg/s and reports position at ~1Hz, so the first echo after
393
+ // a nudge routinely carries the pre-command value.
394
+ // ---------------------------------------------------------------------------
395
+
396
+ describe("PanZoomController: outstanding setpoint", () => {
397
+ const WIDE = { ...DEFAULT_BOUNDS, panYawMin: -180, panYawMax: 180 };
398
+
399
+ it("ignores an echo carrying the pre-command position", () => {
400
+ const { sink, setPan } = makeSink();
401
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
402
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
403
+ ctrl.nudgePan(1, 0);
404
+ expect(setPan).toHaveBeenLastCalledWith(5, 0);
405
+
406
+ // The measured case: an echo lands ~9ms later still reporting the old
407
+ // position. Adopting it would make the next nudge repeat this one.
408
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
409
+ ctrl.nudgePan(1, 0);
410
+ expect(setPan).toHaveBeenLastCalledWith(10, 0);
411
+ });
412
+
413
+ it("releases the setpoint once an echo agrees with it", () => {
414
+ const { sink, setPan } = makeSink();
415
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 5 });
416
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
417
+ ctrl.nudgePan(1, 0); // setpoint 5
418
+ ctrl.syncFromState({ ...WIDE, panYaw: 5 }); // arrived -- setpoint released
419
+ ctrl.syncFromState({ ...WIDE, panYaw: 20 }); // someone else moved it
420
+ ctrl.nudgePan(1, 0);
421
+ expect(setPan).toHaveBeenLastCalledWith(25, 0);
422
+ });
423
+
424
+ it("counts an echo within the settle epsilon as arrival", () => {
425
+ const { sink, setPan } = makeSink();
426
+ const ctrl = new PanZoomController(sink, {
427
+ panNudgeDeg: 5,
428
+ settleEpsilonDeg: 0.5,
429
+ });
430
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
431
+ ctrl.nudgePan(1, 0); // setpoint 5
432
+ ctrl.syncFromState({ ...WIDE, panYaw: 4.7 }); // within 0.5 -- adopted
433
+ ctrl.nudgePan(1, 0);
434
+ expect(setPan).toHaveBeenLastCalledWith(9.7, 0);
435
+ });
436
+
437
+ it("holds until BOTH axes arrive", () => {
438
+ const { sink, setPan } = makeSink();
439
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
440
+ const bounds = { ...WIDE, panPitchMin: -90, panPitchMax: 90 };
441
+ ctrl.syncFromState({ ...bounds, panYaw: 0, panPitch: 0 });
442
+ ctrl.nudgePan(1, 1); // setpoint (30, 30)
443
+ ctrl.syncFromState({ ...bounds, panYaw: 30, panPitch: 0 }); // pitch mid-slew
444
+ ctrl.nudgePan(1, 1);
445
+ expect(setPan).toHaveBeenLastCalledWith(60, 60);
446
+ });
447
+
448
+ it("gives up when the camera moves but stops closing on the setpoint", () => {
449
+ const { sink, setPan } = makeSink();
450
+ const ctrl = new PanZoomController(sink, {
451
+ panNudgeDeg: 30,
452
+ settleStallEchoes: 2,
453
+ });
454
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
455
+ ctrl.nudgePan(1, 0); // setpoint 30
456
+
457
+ // Something else owns the camera (another operator, or auto-track): it is
458
+ // moving, but away from our setpoint.
459
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 }); // closing, no verdict yet
460
+ ctrl.syncFromState({ ...WIDE, panYaw: 5 }); // stall 1
461
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 }); // stall 2 -- adopt reality
462
+ ctrl.nudgePan(1, 0);
463
+ expect(setPan).toHaveBeenLastCalledWith(30, 0);
464
+ });
465
+
466
+ it("does not count repeated identical echoes toward the stall", () => {
467
+ // The sidecar re-broadcasts camera state on unrelated changes (bitrate,
468
+ // degrade), each carrying the same ~1Hz plugin pan reading. Those are not
469
+ // evidence that the camera stopped closing.
470
+ const { sink, setPan } = makeSink();
471
+ const ctrl = new PanZoomController(sink, {
472
+ panNudgeDeg: 30,
473
+ settleStallEchoes: 2,
474
+ });
475
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
476
+ ctrl.nudgePan(1, 0); // setpoint 30
477
+ for (let i = 0; i < 5; i++) ctrl.syncFromState({ ...WIDE, panYaw: 10 });
478
+ ctrl.nudgePan(1, 0);
479
+ expect(setPan).toHaveBeenLastCalledWith(60, 0);
480
+ });
481
+
482
+ it("releases the setpoint after the timeout backstop", async () => {
483
+ vi.useFakeTimers();
484
+ try {
485
+ const { sink, setPan } = makeSink();
486
+ const ctrl = new PanZoomController(sink, {
487
+ panNudgeDeg: 30,
488
+ settleTimeoutMs: 6000,
489
+ });
490
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
491
+ ctrl.nudgePan(1, 0); // setpoint 30
492
+
493
+ // Camera parks short and reports the same position forever, which the
494
+ // stall rule cannot see. The timeout is the way out.
495
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
496
+ await vi.advanceTimersByTimeAsync(6000);
497
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
498
+ ctrl.nudgePan(1, 0);
499
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
500
+ } finally {
501
+ vi.useRealTimers();
502
+ }
503
+ });
504
+
505
+ it("drops the setpoint when a pan rate takes over", () => {
506
+ const { sink, setPan } = makeSink();
507
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
508
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
509
+ ctrl.nudgePan(1, 0); // setpoint 30
510
+ ctrl.setPanRate(0.5, 0);
511
+ ctrl.setPanRate(0, 0); // hold over; camera is wherever the rate left it
512
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
513
+ ctrl.nudgePan(1, 0);
514
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
515
+ });
516
+
517
+ it("drops the setpoint when the ball is grabbed", () => {
518
+ const { sink, setPan } = makeSink();
519
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
520
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
521
+ ctrl.nudgePan(1, 0); // setpoint 30
522
+ ctrl.setBallDragging(true);
523
+ ctrl.setBallDragging(false);
524
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
525
+ ctrl.nudgePan(1, 0);
526
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
527
+ });
528
+
529
+ it("drops the setpoint on stop", () => {
530
+ const { sink, setPan } = makeSink();
531
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
532
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
533
+ ctrl.nudgePan(1, 0); // setpoint 30
534
+ ctrl.stop();
535
+ ctrl.syncFromState({ ...WIDE, panYaw: 10 });
536
+ ctrl.nudgePan(1, 0);
537
+ expect(setPan).toHaveBeenLastCalledWith(40, 0);
538
+ });
539
+
540
+ it("keeps applying echoed bounds while a setpoint is outstanding", () => {
541
+ // Bounds are camera metadata, not a position: holding them back would
542
+ // clamp later nudges against a stale travel limit.
543
+ const { sink, setPan } = makeSink();
544
+ const ctrl = new PanZoomController(sink, { panNudgeDeg: 30 });
545
+ ctrl.syncFromState({ ...WIDE, panYaw: 0 });
546
+ ctrl.nudgePan(1, 0); // setpoint 30
547
+ ctrl.syncFromState({ ...WIDE, panYaw: 0, panYawMax: 45 });
548
+ ctrl.nudgePan(1, 0);
549
+ expect(setPan).toHaveBeenLastCalledWith(45, 0);
550
+ });
551
+
552
+ it("does not let an in-transit fov echo drag the slider back", async () => {
553
+ vi.useFakeTimers();
554
+ try {
555
+ const { sink, setFov } = makeSink();
556
+ const ctrl = new PanZoomController(sink, {
557
+ fovNudgeDeg: 5,
558
+ fovSliderDebounceMs: 120,
559
+ });
560
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
561
+ ctrl.fovSliderInput(30);
562
+ await vi.advanceTimersByTimeAsync(120);
563
+ expect(setFov).toHaveBeenLastCalledWith(30);
564
+
565
+ // Plugin is still slewing 60 -> 30 and reports 60. Adopting it would
566
+ // visibly snap the slider back and re-base the next zoom nudge.
567
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
568
+ expect(ctrl.sliderFov).toBe(30);
569
+ ctrl.nudgeZoom(-1);
570
+ expect(setFov).toHaveBeenLastCalledWith(25);
571
+ } finally {
572
+ vi.useRealTimers();
573
+ }
574
+ });
575
+
576
+ it("releases the fov setpoint once the echo agrees", () => {
577
+ const { sink, setFov } = makeSink();
578
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
579
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
580
+ ctrl.nudgeZoom(-1); // setpoint 55
581
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 55 }); // arrived
582
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 40 }); // someone else zoomed
583
+ ctrl.nudgeZoom(-1);
584
+ expect(setFov).toHaveBeenLastCalledWith(35);
585
+ });
586
+
587
+ it("drops the fov setpoint when a zoom rate takes over", () => {
588
+ const { sink, setFov } = makeSink();
589
+ const ctrl = new PanZoomController(sink, { fovNudgeDeg: 5 });
590
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 60 });
591
+ ctrl.nudgeZoom(-1); // setpoint 55
592
+ ctrl.setZoomRate(1);
593
+ ctrl.setZoomRate(0);
594
+ ctrl.syncFromState({ ...DEFAULT_BOUNDS, fov: 40 });
595
+ ctrl.nudgeZoom(-1);
596
+ expect(setFov).toHaveBeenLastCalledWith(35);
597
+ });
598
+ });
599
+
389
600
  // ---------------------------------------------------------------------------
390
601
  // onSliderFov subscription management
391
602
  // ---------------------------------------------------------------------------
package/src/panZoom.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Headless pan/zoom control state machine extracted from the CameraFeed
3
3
  * component. Manages rate deduplication, analog deadzone, optimistic
4
4
  * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
- * while idle -- all without any DOM or React dependency.
5
+ * while idle and settled -- all without any DOM or React dependency.
6
6
  *
7
7
  * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
8
  * camera handle directly as the sink and the controller will drive it.
@@ -81,6 +81,31 @@ export interface PanZoomControllerOptions {
81
81
  * Default: 0.05.
82
82
  */
83
83
  analogDeadzone?: number;
84
+ /**
85
+ * How close an echo has to get to an outstanding setpoint (degrees) before
86
+ * the camera counts as having arrived. Wider than the plugin's own 0.01
87
+ * degree reporting threshold so a rounding difference does not hold the
88
+ * setpoint open.
89
+ * Default: 0.5.
90
+ */
91
+ settleEpsilonDeg?: number;
92
+ /**
93
+ * How many consecutive *moving* echoes may fail to close on an outstanding
94
+ * setpoint before the controller gives up and adopts what the camera
95
+ * reports. Catches something else owning the camera (another operator, or
96
+ * auto-track) without waiting out the full timeout.
97
+ * Default: 2.
98
+ */
99
+ settleStallEchoes?: number;
100
+ /**
101
+ * Hard cap (ms) on how long a setpoint is held against echoes. Backstop for
102
+ * a camera that parks short of the setpoint and then reports the same
103
+ * position forever (a bounds mismatch would do it), which no
104
+ * progress-based rule can detect. Sized for the worst real case: 360
105
+ * degrees of yaw at 90 deg/s plus the plugin's ~1Hz status write.
106
+ * Default: 6000.
107
+ */
108
+ settleTimeoutMs?: number;
84
109
  }
85
110
 
86
111
  // ---------------------------------------------------------------------------
@@ -95,6 +120,27 @@ function applyDeadzone(v: number, deadzone: number): number {
95
120
  return Math.abs(v) < deadzone ? 0 : v;
96
121
  }
97
122
 
123
+ /*
124
+ Bookkeeping for an absolute setpoint the camera has been asked for but has
125
+ not been observed to reach. The plugin slews toward a setpoint at a fixed
126
+ rate (90 deg/s of yaw, 60 deg/s of FoV) and reports its position at ~1Hz, so
127
+ echoes arriving during the slew carry positions the camera has already left.
128
+ Feeding those to the accumulator would re-base the next nudge on a stale
129
+ position and lose travel.
130
+ */
131
+ interface Settling {
132
+ /* Remaining distance measured at the previous echo; null before the first. */
133
+ lastDistance: number | null;
134
+ /* Consecutive moving echoes that failed to close on the setpoint. */
135
+ stalls: number;
136
+ /* Wall clock at issue, for the timeout backstop. */
137
+ issuedAt: number;
138
+ }
139
+
140
+ function newSettling(): Settling {
141
+ return { lastDistance: null, stalls: 0, issuedAt: Date.now() };
142
+ }
143
+
98
144
  // ---------------------------------------------------------------------------
99
145
  // PanZoomController
100
146
  // ---------------------------------------------------------------------------
@@ -111,6 +157,9 @@ export class PanZoomController {
111
157
  private readonly fovNudgeDeg: number;
112
158
  private readonly fovSliderDebounceMs: number;
113
159
  private readonly analogDeadzone: number;
160
+ private readonly settleEpsilonDeg: number;
161
+ private readonly settleStallEchoes: number;
162
+ private readonly settleTimeoutMs: number;
114
163
 
115
164
  // Last-sent rates (dedupe against re-sending the same value).
116
165
  private sentPanRate = { yaw: 0, pitch: 0 };
@@ -120,6 +169,16 @@ export class PanZoomController {
120
169
  private localPan = { yaw: 0, pitch: 0 };
121
170
  private localFov = 0;
122
171
 
172
+ /*
173
+ Outstanding absolute setpoints. Non-null from the moment a setPan / setFov
174
+ goes out until an echo shows the camera got there (or one of `settled`'s
175
+ give-up rules fires). While one is outstanding the accumulator holds the
176
+ setpoint and echoes do not touch it.
177
+ */
178
+ private panSetpoint: (Settling & { yaw: number; pitch: number }) | null =
179
+ null;
180
+ private fovSetpoint: (Settling & { fov: number }) | null = null;
181
+
123
182
  // FoV slider state.
124
183
  private _sliderFov = 0;
125
184
  private sliderDragging = false;
@@ -148,6 +207,9 @@ export class PanZoomController {
148
207
  this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
149
208
  this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
150
209
  this.analogDeadzone = opts.analogDeadzone ?? 0.05;
210
+ this.settleEpsilonDeg = opts.settleEpsilonDeg ?? 0.5;
211
+ this.settleStallEchoes = opts.settleStallEchoes ?? 2;
212
+ this.settleTimeoutMs = opts.settleTimeoutMs ?? 6000;
151
213
  }
152
214
 
153
215
  // ---------------------------------------------------------------------------
@@ -174,6 +236,12 @@ export class PanZoomController {
174
236
  * Applies the echoed FoV to the accumulator and slider only when the
175
237
  * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
176
238
  * debounced send). Mirrors CameraFeed.tsx:370-387.
239
+ *
240
+ * An echo is also held off while an absolute setpoint we sent is still
241
+ * outstanding: the plugin slews toward a setpoint and reports its position
242
+ * at ~1Hz, so echoes in that window carry positions the camera has already
243
+ * left, and adopting one would re-base the next nudge behind where the
244
+ * camera is going.
177
245
  */
178
246
  syncFromState(
179
247
  state: { fov: number; panYaw: number; panPitch: number } & PanZoomBounds,
@@ -191,7 +259,7 @@ export class PanZoomController {
191
259
  !this.ballDragging &&
192
260
  this.sentPanRate.yaw === 0 &&
193
261
  this.sentPanRate.pitch === 0;
194
- if (panIdle) {
262
+ if (panIdle && this.panEchoWins(state.panYaw, state.panPitch)) {
195
263
  this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
196
264
  }
197
265
 
@@ -199,7 +267,7 @@ export class PanZoomController {
199
267
  this.sentZoomRate === 0 &&
200
268
  !this.sliderDragging &&
201
269
  this.pendingFov === null;
202
- if (zoomIdle) {
270
+ if (zoomIdle && this.fovEchoWins(state.fov)) {
203
271
  this.localFov = state.fov;
204
272
  this.setSliderFovInternal(state.fov);
205
273
  }
@@ -219,6 +287,9 @@ export class PanZoomController {
219
287
  const p = applyDeadzone(clamp(pitch, -1, 1), this.analogDeadzone);
220
288
  if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch) return;
221
289
  this.sentPanRate = { yaw: y, pitch: p };
290
+ // A rate supersedes any absolute we were still waiting on, including the
291
+ // zero that ends the hold: the camera is wherever the rate left it.
292
+ this.panSetpoint = null;
222
293
  void this.sink.setPanRate(y, p);
223
294
  }
224
295
 
@@ -245,6 +316,7 @@ export class PanZoomController {
245
316
  const r = applyDeadzone(clamp(rate, -1, 1), this.analogDeadzone);
246
317
  if (r === this.sentZoomRate) return;
247
318
  this.sentZoomRate = r;
319
+ this.fovSetpoint = null; // same reasoning as setPanRate
248
320
  void this.sink.setZoomRate(r);
249
321
  }
250
322
 
@@ -269,6 +341,11 @@ export class PanZoomController {
269
341
  b.panPitchMin,
270
342
  b.panPitchMax,
271
343
  );
344
+ this.panSetpoint = {
345
+ yaw: this.localPan.yaw,
346
+ pitch: this.localPan.pitch,
347
+ ...newSettling(),
348
+ };
272
349
  void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
273
350
  }
274
351
 
@@ -285,6 +362,7 @@ export class PanZoomController {
285
362
  b.fovMax,
286
363
  );
287
364
  this.localFov = next;
365
+ this.fovSetpoint = { fov: next, ...newSettling() };
288
366
  void this.sink.setFov(next);
289
367
  }
290
368
 
@@ -342,6 +420,8 @@ export class PanZoomController {
342
420
  */
343
421
  setBallDragging(dragging: boolean): void {
344
422
  this.ballDragging = dragging;
423
+ // Taking the ball abandons any absolute we were waiting on.
424
+ if (dragging) this.panSetpoint = null;
345
425
  }
346
426
 
347
427
  // ---------------------------------------------------------------------------
@@ -365,6 +445,9 @@ export class PanZoomController {
365
445
  }
366
446
  this.ballDragging = false;
367
447
  this.sliderDragging = false;
448
+ // Nothing is being commanded any more, so echoes win again.
449
+ this.panSetpoint = null;
450
+ this.fovSetpoint = null;
368
451
  if (this.fovDebounceTimer !== null) {
369
452
  clearTimeout(this.fovDebounceTimer);
370
453
  this.fovDebounceTimer = null;
@@ -394,10 +477,59 @@ export class PanZoomController {
394
477
  const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
395
478
  this.localFov = fov;
396
479
  this.pendingFov = null;
480
+ this.fovSetpoint = { fov, ...newSettling() };
397
481
  void this.sink.setFov(fov);
398
482
  }
399
483
  }
400
484
 
485
+ /*
486
+ Whether an echoed pan should be adopted into the accumulator. True when
487
+ nothing is outstanding, or when the outstanding setpoint has been settled
488
+ one way or another (in which case it is cleared and the camera's own
489
+ reading wins from here).
490
+ */
491
+ private panEchoWins(yaw: number, pitch: number): boolean {
492
+ const sp = this.panSetpoint;
493
+ if (sp === null) return true;
494
+ const distance = Math.max(
495
+ Math.abs(yaw - sp.yaw),
496
+ Math.abs(pitch - sp.pitch),
497
+ );
498
+ if (!this.settled(sp, distance)) return false;
499
+ this.panSetpoint = null;
500
+ return true;
501
+ }
502
+
503
+ /* `panEchoWins` for FoV. */
504
+ private fovEchoWins(fov: number): boolean {
505
+ const sp = this.fovSetpoint;
506
+ if (sp === null) return true;
507
+ if (!this.settled(sp, Math.abs(fov - sp.fov))) return false;
508
+ this.fovSetpoint = null;
509
+ return true;
510
+ }
511
+
512
+ /*
513
+ Give up on an outstanding setpoint when the camera arrived, when it is
514
+ moving but no longer closing on it (something else owns the camera: another
515
+ operator, or auto-track), or when the timeout backstop expires (it parked
516
+ short and is reporting the same position forever, which no progress-based
517
+ rule can see). Mutates the settling bookkeeping.
518
+ */
519
+ private settled(sp: Settling, distance: number): boolean {
520
+ const eps = this.settleEpsilonDeg;
521
+ if (distance <= eps) return true;
522
+ if (Date.now() - sp.issuedAt >= this.settleTimeoutMs) return true;
523
+ const prev = sp.lastDistance;
524
+ sp.lastDistance = distance;
525
+ if (prev === null) return false;
526
+ const moved = Math.abs(prev - distance) > eps;
527
+ const closing = distance < prev - eps;
528
+ if (!moved) return false;
529
+ sp.stalls = closing ? 0 : sp.stalls + 1;
530
+ return sp.stalls >= this.settleStallEchoes;
531
+ }
532
+
401
533
  private setSliderFovInternal(fov: number): void {
402
534
  this._sliderFov = fov;
403
535
  for (const cb of this.sliderFovListeners) cb(fov);