@ksp-gonogo/kerbcast 1.4.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.
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Headless pan/zoom control state machine extracted from the CameraFeed
3
+ * component. Manages rate deduplication, analog deadzone, optimistic
4
+ * accumulators for discrete nudges, debounced FoV slider, and echo-sync
5
+ * while idle -- all without any DOM or React dependency.
6
+ *
7
+ * `KerbcastCameraHandle` satisfies `PanZoomCommandSink` structurally: pass a
8
+ * camera handle directly as the sink and the controller will drive it.
9
+ *
10
+ * ```ts
11
+ * const cam = client.camera(flightId);
12
+ * const ctrl = new PanZoomController(cam);
13
+ *
14
+ * // Analog stick input
15
+ * ctrl.setPanRate(stickX, stickY);
16
+ *
17
+ * // Discrete nudge buttons
18
+ * ctrl.nudgePan(1, 0); // pan right one step
19
+ * ctrl.nudgeZoom(-1); // zoom in one step
20
+ *
21
+ * // FoV slider
22
+ * ctrl.setFovSliderDragging(true);
23
+ * ctrl.fovSliderInput(newFov);
24
+ * ctrl.setFovSliderDragging(false); // flushes immediately
25
+ *
26
+ * // Sync echoed state (call from camera "change" listener)
27
+ * ctrl.syncFromState({ fov, panYaw, panPitch, fovMin, fovMax,
28
+ * panYawMin, panYawMax, panPitchMin, panPitchMax });
29
+ *
30
+ * // Cleanup on unmount
31
+ * ctrl.stop();
32
+ * ```
33
+ */
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+ function clamp(v, lo, hi) {
38
+ return Math.max(lo, Math.min(hi, v));
39
+ }
40
+ function applyDeadzone(v, deadzone) {
41
+ return Math.abs(v) < deadzone ? 0 : v;
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // PanZoomController
45
+ // ---------------------------------------------------------------------------
46
+ /**
47
+ * Headless pan/zoom state machine. Manages rate deduplication, analog
48
+ * deadzone, optimistic accumulators, FoV slider debounce, and echo-sync
49
+ * idle rules. Mirrors the behaviour of the CameraFeed component in gonogo
50
+ * (CameraFeed.tsx lines 323-592) without any React or DOM coupling.
51
+ */
52
+ export class PanZoomController {
53
+ constructor(sink, opts = {}) {
54
+ // Last-sent rates (dedupe against re-sending the same value).
55
+ this.sentPanRate = { yaw: 0, pitch: 0 };
56
+ this.sentZoomRate = 0;
57
+ // Optimistic accumulators for discrete nudges.
58
+ this.localPan = { yaw: 0, pitch: 0 };
59
+ this.localFov = 0;
60
+ // FoV slider state.
61
+ this._sliderFov = 0;
62
+ this.sliderDragging = false;
63
+ this.fovDebounceTimer = null;
64
+ this.pendingFov = null;
65
+ // Drag-ball flag (used only for the idle rule; pixel math lives in the UI).
66
+ this.ballDragging = false;
67
+ // Last-known bounds for clamping.
68
+ this.bounds = {
69
+ fovMin: 0,
70
+ fovMax: 180,
71
+ panYawMin: -180,
72
+ panYawMax: 180,
73
+ panPitchMin: -90,
74
+ panPitchMax: 90,
75
+ };
76
+ // sliderFov change subscribers.
77
+ this.sliderFovListeners = new Set();
78
+ this.sink = sink;
79
+ this.panNudgeDeg = opts.panNudgeDeg ?? 5;
80
+ this.fovNudgeDeg = opts.fovNudgeDeg ?? 5;
81
+ this.fovSliderDebounceMs = opts.fovSliderDebounceMs ?? 120;
82
+ this.analogDeadzone = opts.analogDeadzone ?? 0.05;
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Public getters
86
+ // ---------------------------------------------------------------------------
87
+ /**
88
+ * Optimistic FoV value tracked by the slider. Follows the pointer while
89
+ * dragging, then the camera echo when idle. Reactive via
90
+ * {@link onSliderFov}.
91
+ */
92
+ get sliderFov() {
93
+ return this._sliderFov;
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Echo-sync
97
+ // ---------------------------------------------------------------------------
98
+ /**
99
+ * Sync from the camera's echoed state (call from the camera's `"change"`
100
+ * event listener). Applies the echoed pan to the pan accumulator only when
101
+ * the controller is pan-idle (no active pan rate, no active ball drag).
102
+ * Applies the echoed FoV to the accumulator and slider only when the
103
+ * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
104
+ * debounced send). Mirrors CameraFeed.tsx:370-387.
105
+ */
106
+ syncFromState(state) {
107
+ this.bounds = {
108
+ fovMin: state.fovMin,
109
+ fovMax: state.fovMax,
110
+ panYawMin: state.panYawMin,
111
+ panYawMax: state.panYawMax,
112
+ panPitchMin: state.panPitchMin,
113
+ panPitchMax: state.panPitchMax,
114
+ };
115
+ const panIdle = !this.ballDragging &&
116
+ this.sentPanRate.yaw === 0 &&
117
+ this.sentPanRate.pitch === 0;
118
+ if (panIdle) {
119
+ this.localPan = { yaw: state.panYaw, pitch: state.panPitch };
120
+ }
121
+ const zoomIdle = this.sentZoomRate === 0 &&
122
+ !this.sliderDragging &&
123
+ this.pendingFov === null;
124
+ if (zoomIdle) {
125
+ this.localFov = state.fov;
126
+ this.setSliderFovInternal(state.fov);
127
+ }
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Pan rate
131
+ // ---------------------------------------------------------------------------
132
+ /**
133
+ * Set a persistent normalised pan velocity (-1..1 per axis). Clamps,
134
+ * applies the analog deadzone, and deduplicates against the last-sent
135
+ * value before forwarding to the sink. Mirrors sendPanRate.
136
+ */
137
+ setPanRate(yaw, pitch) {
138
+ const y = applyDeadzone(clamp(yaw, -1, 1), this.analogDeadzone);
139
+ const p = applyDeadzone(clamp(pitch, -1, 1), this.analogDeadzone);
140
+ if (y === this.sentPanRate.yaw && p === this.sentPanRate.pitch)
141
+ return;
142
+ this.sentPanRate = { yaw: y, pitch: p };
143
+ void this.sink.setPanRate(y, p);
144
+ }
145
+ /**
146
+ * Update one pan axis, preserving the other. Allows two independent inputs
147
+ * (e.g. a serial stick axis and the ball) to compose rather than clobber
148
+ * each other. Mirrors setPanAxis.
149
+ */
150
+ setPanAxis(axis, value) {
151
+ const cur = this.sentPanRate;
152
+ if (axis === "yaw")
153
+ this.setPanRate(value, cur.pitch);
154
+ else
155
+ this.setPanRate(cur.yaw, value);
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Zoom rate
159
+ // ---------------------------------------------------------------------------
160
+ /**
161
+ * Set a persistent normalised zoom velocity (-1..1; +1 = zoom in, FoV
162
+ * decreasing). Clamps, applies deadzone, deduplicates. Mirrors sendZoomRate.
163
+ */
164
+ setZoomRate(rate) {
165
+ const r = applyDeadzone(clamp(rate, -1, 1), this.analogDeadzone);
166
+ if (r === this.sentZoomRate)
167
+ return;
168
+ this.sentZoomRate = r;
169
+ void this.sink.setZoomRate(r);
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Discrete nudges
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Discrete pan step (+1/-1 per axis sign). Moves the pan accumulator by
176
+ * `panNudgeDeg`, clamped to bounds, then sends an absolute `setPan`.
177
+ * Mirrors nudgePan.
178
+ */
179
+ nudgePan(yawSign, pitchSign) {
180
+ const b = this.bounds;
181
+ this.localPan.yaw = clamp(this.localPan.yaw + yawSign * this.panNudgeDeg, b.panYawMin, b.panYawMax);
182
+ this.localPan.pitch = clamp(this.localPan.pitch + pitchSign * this.panNudgeDeg, b.panPitchMin, b.panPitchMax);
183
+ void this.sink.setPan(this.localPan.yaw, this.localPan.pitch);
184
+ }
185
+ /**
186
+ * Discrete FoV step. `deltaSign: -1` = zoom in (FoV decreases by
187
+ * `fovNudgeDeg`), `+1` = zoom out (FoV increases). Clamped to fov bounds.
188
+ * Mirrors nudgeZoom (which calls onFovChange internally).
189
+ */
190
+ nudgeZoom(deltaSign) {
191
+ const b = this.bounds;
192
+ const next = clamp(this.localFov + deltaSign * this.fovNudgeDeg, b.fovMin, b.fovMax);
193
+ this.localFov = next;
194
+ void this.sink.setFov(next);
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // FoV slider
198
+ // ---------------------------------------------------------------------------
199
+ /**
200
+ * Update the optimistic slider value and schedule a debounced `setFov`.
201
+ * The settled (paused) value is what gets sent; rapid drag input does not
202
+ * stream a command per pixel. Mirrors scheduleFovSlider.
203
+ */
204
+ fovSliderInput(fov) {
205
+ this.pendingFov = fov;
206
+ this.setSliderFovInternal(fov);
207
+ if (this.fovDebounceTimer !== null)
208
+ clearTimeout(this.fovDebounceTimer);
209
+ this.fovDebounceTimer = setTimeout(() => {
210
+ this.fovDebounceTimer = null;
211
+ this.flushFovSlider();
212
+ }, this.fovSliderDebounceMs);
213
+ }
214
+ /**
215
+ * Declare slider drag start/end. While dragging, echo-sync does not
216
+ * override the slider (the user is in control). Setting `false` flushes
217
+ * any pending debounced FoV immediately. Mirrors the pointer-up
218
+ * flushFovSlider call.
219
+ */
220
+ setFovSliderDragging(dragging) {
221
+ this.sliderDragging = dragging;
222
+ if (!dragging)
223
+ this.flushFovSlider();
224
+ }
225
+ /**
226
+ * Subscribe to slider FoV changes. The callback fires on `fovSliderInput`
227
+ * and on idle echo-sync (when the camera echo updates the slider). Returns
228
+ * an unsubscribe function.
229
+ */
230
+ onSliderFov(cb) {
231
+ this.sliderFovListeners.add(cb);
232
+ return () => {
233
+ this.sliderFovListeners.delete(cb);
234
+ };
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Ball drag
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * Declare ball-drag start/end. While dragging, echo-sync does not apply
241
+ * the echoed pan to the local accumulator (the user is in control).
242
+ * The pixel-deflection-to-rate conversion happens in the UI layer; the
243
+ * UI calls `setPanRate` with the normalised result.
244
+ */
245
+ setBallDragging(dragging) {
246
+ this.ballDragging = dragging;
247
+ }
248
+ // ---------------------------------------------------------------------------
249
+ // Lifecycle
250
+ // ---------------------------------------------------------------------------
251
+ /**
252
+ * Send zero pan and zoom rates if (and only if) a non-zero rate is currently
253
+ * active, and clear drag flags and any pending FoV timer without sending it.
254
+ * Use on component unmount or camera hide -- mirrors the cleanup effects at
255
+ * CameraFeed.tsx:477-508.
256
+ */
257
+ stop() {
258
+ if (this.sentPanRate.yaw !== 0 || this.sentPanRate.pitch !== 0) {
259
+ this.sentPanRate = { yaw: 0, pitch: 0 };
260
+ void this.sink.setPanRate(0, 0);
261
+ }
262
+ if (this.sentZoomRate !== 0) {
263
+ this.sentZoomRate = 0;
264
+ void this.sink.setZoomRate(0);
265
+ }
266
+ this.ballDragging = false;
267
+ this.sliderDragging = false;
268
+ if (this.fovDebounceTimer !== null) {
269
+ clearTimeout(this.fovDebounceTimer);
270
+ this.fovDebounceTimer = null;
271
+ }
272
+ this.pendingFov = null;
273
+ }
274
+ /**
275
+ * Stop the controller and drop all slider-change subscribers. Call when
276
+ * the owning component is permanently destroyed.
277
+ */
278
+ dispose() {
279
+ this.stop();
280
+ this.sliderFovListeners.clear();
281
+ }
282
+ // ---------------------------------------------------------------------------
283
+ // Private helpers
284
+ // ---------------------------------------------------------------------------
285
+ flushFovSlider() {
286
+ if (this.fovDebounceTimer !== null) {
287
+ clearTimeout(this.fovDebounceTimer);
288
+ this.fovDebounceTimer = null;
289
+ }
290
+ if (this.pendingFov !== null) {
291
+ const fov = clamp(this.pendingFov, this.bounds.fovMin, this.bounds.fovMax);
292
+ this.localFov = fov;
293
+ this.pendingFov = null;
294
+ void this.sink.setFov(fov);
295
+ }
296
+ }
297
+ setSliderFovInternal(fov) {
298
+ this._sliderFov = fov;
299
+ for (const cb of this.sliderFovListeners)
300
+ cb(fov);
301
+ }
302
+ }
@@ -0,0 +1,217 @@
1
+ import type { AdaptiveShedPayload, CameraState, ClientMessage, ErrorPayload, SettingsStatePayload } from "../__generated__/types";
2
+ import { CameraLifecycle, Layer, QualityPreset } from "../__generated__/types";
3
+ import type { InboundVideoStats, KerbcastConnectionState, KerbcastTransport } from "../client";
4
+ export interface MockCameraInit {
5
+ flightId: number;
6
+ lifecycle?: CameraLifecycle;
7
+ partName?: string;
8
+ partTitle?: string;
9
+ cameraName?: string;
10
+ vesselName?: string;
11
+ layers?: Layer[];
12
+ operatorLayers?: Layer[];
13
+ renderWidth?: number;
14
+ renderHeight?: number;
15
+ operatorWidth?: number;
16
+ operatorHeight?: number;
17
+ supportsZoom?: boolean;
18
+ fov?: number;
19
+ fovMin?: number;
20
+ fovMax?: number;
21
+ supportsPan?: boolean;
22
+ panYaw?: number;
23
+ panPitch?: number;
24
+ panYawMin?: number;
25
+ panYawMax?: number;
26
+ panPitchMin?: number;
27
+ panPitchMax?: number;
28
+ encoderBitrateBps?: number;
29
+ targetBitrateBps?: number;
30
+ degradeLevel?: number;
31
+ viewerQuality?: QualityPreset;
32
+ qualityLimitedBy?: string;
33
+ }
34
+ /**
35
+ * In-process protocol-level fake for the kerbcast sidecar.
36
+ *
37
+ * Owns a camera registry and speaks the full kerbcast wire protocol.
38
+ * Use it in tests to exercise `KerbcastClient` behaviour without a real
39
+ * sidecar or WebRTC stack.
40
+ *
41
+ * ```ts
42
+ * const sidecar = new MockSidecar();
43
+ * sidecar.addCamera({ flightId: 42 });
44
+ *
45
+ * vi.spyOn(globalThis, "fetch").mockImplementation(() =>
46
+ * Promise.resolve(MockSidecar.makeOfferResponse([42]))
47
+ * );
48
+ *
49
+ * const client = new KerbcastClient({ host: "localhost", port: 8088 }, sidecar.createTransport());
50
+ * await client.connect([42]);
51
+ * sidecar.open(); // fires hello + camera-snapshot
52
+ *
53
+ * expect(client.cameras[0].flightId).toBe(42);
54
+ * ```
55
+ */
56
+ export declare class MockSidecar {
57
+ private readonly _cameras;
58
+ private readonly _commands;
59
+ private _throttleMainScreen;
60
+ private _openHandler;
61
+ private _clientMsgHandler;
62
+ private _stateHandler;
63
+ private _onTrackHandler;
64
+ private _trackIdx;
65
+ /** Slot mids available for the dynamic-subscription model. Override with
66
+ * {@link withSlots} before connecting if a test needs a specific pool. */
67
+ private _slotMids;
68
+ /** mid -> camera currently bound to that slot. */
69
+ private readonly _slotBindings;
70
+ /**
71
+ * Per-flight inbound stats to return from the fake getStats. Set via
72
+ * {@link setInboundStats}. Keyed by flightId.
73
+ */
74
+ private readonly _inboundStats;
75
+ /**
76
+ * Tracks delivered by {@link deliverTrack}, keyed by mid/idx string so
77
+ * getStats can synthesize a trackIdentifier matching what the client saw.
78
+ */
79
+ private readonly _deliveredTracks;
80
+ /** Register a camera that will appear in the `camera-snapshot` sent on `open()`. */
81
+ addCamera(init: MockCameraInit): void;
82
+ /**
83
+ * Returns a `KerbcastTransport` backed by this mock. Pass it as the
84
+ * second argument to `KerbcastClient`.
85
+ */
86
+ createTransport(): KerbcastTransport;
87
+ /**
88
+ * Simulate the sidecar completing the WebRTC handshake. Fires the
89
+ * channel `onOpen` handler (which triggers the client's `hello`), then
90
+ * responds with `hello` + `camera-snapshot`.
91
+ */
92
+ open(): void;
93
+ /** Drive the underlying peer's connection-state handler. */
94
+ setConnectionState(state: KerbcastConnectionState): void;
95
+ /**
96
+ * Mark a camera as destroyed and push a `camera-state-changed` message
97
+ * to the client. The camera stays in the internal registry with
98
+ * `lifecycle: Destroyed`.
99
+ */
100
+ destroyCamera(flightId: number): void;
101
+ /**
102
+ * Apply a partial update to an existing camera and push a
103
+ * `camera-state-changed` message to the client.
104
+ */
105
+ updateCamera(flightId: number, partial: Partial<CameraState>): void;
106
+ /**
107
+ * Replace the entire camera registry and push a fresh `camera-snapshot` to
108
+ * the client. Models a vessel change / scene switch where the set of
109
+ * available cameras changes (cameras appear or disappear) — distinct from
110
+ * {@link destroyCamera}, which keeps the camera present but `Destroyed`.
111
+ */
112
+ setCameras(inits: MockCameraInit[]): void;
113
+ /** Send a `ping` from the sidecar; the client should respond with `pong`. */
114
+ firePing(): void;
115
+ /** Push an `adaptive-shed` event to the client. */
116
+ fireAdaptiveShed(payload: AdaptiveShedPayload): void;
117
+ /** Push an `error` event to the client (simulates a sidecar error reply). */
118
+ fireError(payload: ErrorPayload): void;
119
+ /** Current mock-sidecar throttle state (reflects `set-throttle-main-screen` commands). */
120
+ get throttleMainScreen(): boolean;
121
+ /** Push a `settings-state` event to the client (simulates a plugin-status-change broadcast). */
122
+ fireSettingsState(payload: SettingsStatePayload): void;
123
+ /** Configure the slot-pool mids before connecting (dynamic mode). */
124
+ withSlots(mids: string[]): this;
125
+ /**
126
+ * Deliver a track onto a slot (by mid), simulating the slot's media
127
+ * arriving over WebRTC. The client routes it to whichever camera is bound
128
+ * to that mid. (jsdom can't make real tracks; pass a stub in unit tests or
129
+ * a `canvas.captureStream()` track in a real-browser harness.)
130
+ *
131
+ * The track is remembered so the fake `getStats()` can synthesize a
132
+ * `trackIdentifier` matching what the client received.
133
+ */
134
+ deliverTrack(mid: string, track: MediaStreamTrack): void;
135
+ /** The slot mid currently carrying `flightId`, or undefined. */
136
+ slotMidFor(flightId: number): string | undefined;
137
+ /** Every `ClientMessage` received from the client, in order. */
138
+ get commands(): ReadonlyArray<ClientMessage>;
139
+ /**
140
+ * Find the most recent client command of the given type. Pass `flightId`
141
+ * to further filter by camera (ignored for message types without a
142
+ * `content.flightId` field).
143
+ */
144
+ lastCommand<T extends ClientMessage["type"]>(type: T, flightId?: number): Extract<ClientMessage, {
145
+ type: T;
146
+ }> | undefined;
147
+ /**
148
+ * Build a `Response` that looks like the sidecar's `POST /offer` reply.
149
+ * Pass to `vi.spyOn(globalThis, "fetch").mockImplementation(...)` so the
150
+ * client's handshake can complete without a real HTTP server.
151
+ *
152
+ * ```ts
153
+ * vi.spyOn(globalThis, "fetch").mockImplementation(() =>
154
+ * Promise.resolve(MockSidecar.makeOfferResponse([42]))
155
+ * );
156
+ * ```
157
+ */
158
+ static makeOfferResponse(cameras: number[]): Response;
159
+ /**
160
+ * Signaling-seam analogue of {@link makeOfferResponse}: resolve an offer's
161
+ * answer without HTTP. Pass as the client's `negotiate` config to exercise
162
+ * the brokered-signaling path a station uses.
163
+ */
164
+ negotiate(offer: {
165
+ sdp: string;
166
+ cameras: number[];
167
+ slots?: number;
168
+ }): Promise<{
169
+ sdp: string;
170
+ cameras: number[];
171
+ }>;
172
+ /**
173
+ * Configure the inbound video stats the fake `getStats()` will return for
174
+ * a given camera. Call before `client.inboundVideoStats()` in tests.
175
+ *
176
+ * The mock synthesizes a minimal `RTCStatsReport`-shaped object: one entry
177
+ * per flight that has stats set, with `type: "inbound-rtp"`, `kind: "video"`,
178
+ * and either a `trackIdentifier` matching the track delivered for that camera
179
+ * (legacy path) or a `mid` matching the slot binding (dynamic path), plus
180
+ * the stat fields from `partialStats`.
181
+ *
182
+ * ```ts
183
+ * sidecar.setInboundStats(42, { packetsReceived: 1000, framesDecoded: 300 });
184
+ * const stats = await client.inboundVideoStats();
185
+ * expect(stats.get(42)?.packetsReceived).toBe(1000);
186
+ * ```
187
+ */
188
+ setInboundStats(flightId: number, partialStats: Partial<InboundVideoStats>): void;
189
+ /** Build a minimal RTCStatsReport-compatible object for the current state. */
190
+ private _buildStatsReport;
191
+ private _sendToClient;
192
+ private _handleClientMessage;
193
+ }
194
+ /**
195
+ * Install jsdom shims needed by kerbcast component tests.
196
+ *
197
+ * jsdom omits several browser APIs that the SDK and React components call at
198
+ * construction or mount time. Stubbing here keeps individual tests clean.
199
+ * Each shim is idempotent so setup files can call `installDomStubs()`
200
+ * unconditionally.
201
+ *
202
+ * Stubs installed:
203
+ * ResizeObserver - jsdom does not implement it; CameraFeed uses it to
204
+ * drive auto render-size updates.
205
+ * captureStream - jsdom's HTMLCanvasElement lacks captureStream; the
206
+ * noise pipeline calls it to get its output MediaStream.
207
+ * Returns a stub MediaStream so callers receive a valid
208
+ * object rather than throwing.
209
+ * MediaStream - jsdom's MediaStream constructor is incomplete; tests
210
+ * need to construct instances for track delivery and
211
+ * stream assertions.
212
+ * play - jsdom prints "Not implemented" for HTMLMediaElement.play;
213
+ * the noise pipeline awaits it on the internal video element.
214
+ * matchMedia - jsdom does not implement window.matchMedia; theme
215
+ * detection reads prefers-color-scheme through it.
216
+ */
217
+ export declare function installDomStubs(): void;