@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,3 @@
1
+ export * from "./__generated__/types";
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
+ export { PanZoomController, type PanZoomCommandSink, type PanZoomBounds, type PanZoomControllerOptions, } from "./panZoom";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Re-export every type the typeshare codegen emits from the Rust
2
+ // protocol module. New code should prefer the higher-level
3
+ // `KerbcastClient` re-exported below, but the underlying wire types
4
+ // stay available for consumers that want to roll their own transport.
5
+ export * from "./__generated__/types";
6
+ export { KerbcastClient, BrowserKerbcastTransport, } from "./client";
7
+ export { PanZoomController, } from "./panZoom";
@@ -0,0 +1,34 @@
1
+ export interface NoisePipelineOptions {
2
+ /**
3
+ * Composite animated static over stalled and sourceless states (default
4
+ * true). When false: a stalled source holds its last decoded frame with no
5
+ * static (the consumer provides its own staleness chrome), and a sourceless
6
+ * path clears to black without noise (only the text overlay renders).
7
+ */
8
+ showStatic?: boolean;
9
+ /** Observe stall transitions (frames stopped / frames resumed). */
10
+ onStallChange?: (stalled: boolean) => void;
11
+ }
12
+ export interface NoisePipeline {
13
+ readonly processedStream: MediaStream;
14
+ setIntensity(level: number): void;
15
+ /**
16
+ * Swap (or remove) the source video. With a non-null stream the pipeline
17
+ * composites static over the live frame; with `null` it goes *sourceless*
18
+ * — `draw()` skips `drawImage` and renders pure static. The output
19
+ * `processedStream` stays valid across the swap, so consumers never see a
20
+ * gap.
21
+ */
22
+ setSource(stream: MediaStream | null): void;
23
+ /** Runtime switch for {@link NoisePipelineOptions.showStatic}. */
24
+ setShowStatic(enabled: boolean): void;
25
+ destroy(): void;
26
+ }
27
+ /**
28
+ * Wraps a (possibly absent) `MediaStream` in a canvas pipeline that
29
+ * composites digital static over the video. Pass `null` to run the pipeline
30
+ * sourceless — it emits pure static until a source is attached via
31
+ * {@link NoisePipeline.setSource}. Returns null when `captureStream` is
32
+ * unavailable (SSR, test environments without canvas support).
33
+ */
34
+ export declare function tryCreateNoisePipeline(rawStream: MediaStream | null, initialIntensity: number, options?: NoisePipelineOptions): NoisePipeline | null;
package/dist/noise.js ADDED
@@ -0,0 +1,274 @@
1
+ const NOISE_MAX_W = 320;
2
+ const NOISE_MAX_H = 180;
3
+ /*
4
+ * A live source that hasn't presented a decoded frame for this long is
5
+ * treated as stalled: the pipeline composites the same black + full-static
6
+ * look it uses when sourceless. Without this, a starved or mid-GOP WebRTC
7
+ * track shows the BROWSER DECODER's output — grey-green macroblock smear —
8
+ * which reads as a second, inconsistent "static" style. Long enough that a
9
+ * single late frame at 30 fps never flickers the overlay.
10
+ */
11
+ const SOURCE_STALL_MS = 500;
12
+ /*
13
+ * When a source that WAS delivering frames stalls, the full-static look
14
+ * ramps in over this window on top of the held last frame instead of
15
+ * cutting hard. Only degradation is softened: the first decoded frame
16
+ * drops the static outright, so recovery feels instant. Sources that
17
+ * never decoded a frame (fresh attach) keep the immediate full-static
18
+ * "waiting" look: there is no last frame to soften from.
19
+ */
20
+ const STALL_RAMP_MS = 5000;
21
+ /**
22
+ * Wraps a (possibly absent) `MediaStream` in a canvas pipeline that
23
+ * composites digital static over the video. Pass `null` to run the pipeline
24
+ * sourceless — it emits pure static until a source is attached via
25
+ * {@link NoisePipeline.setSource}. Returns null when `captureStream` is
26
+ * unavailable (SSR, test environments without canvas support).
27
+ */
28
+ export function tryCreateNoisePipeline(rawStream, initialIntensity, options) {
29
+ const canvas = document.createElement("canvas");
30
+ if (typeof canvas.captureStream !== "function") {
31
+ return null;
32
+ }
33
+ const noiseCanvas = document.createElement("canvas");
34
+ const noiseCtx = noiseCanvas.getContext("2d");
35
+ const ctx = canvas.getContext("2d");
36
+ if (!noiseCtx || !ctx)
37
+ return null;
38
+ // Size the output canvas to video dimensions once metadata loads.
39
+ // Until then (and while sourceless), use a placeholder so captureStream
40
+ // has something to output.
41
+ canvas.width = NOISE_MAX_W;
42
+ canvas.height = NOISE_MAX_H;
43
+ noiseCanvas.width = Math.ceil(NOISE_MAX_W / 2);
44
+ noiseCanvas.height = Math.ceil(NOISE_MAX_H / 2);
45
+ let intensity = initialIntensity;
46
+ let showStatic = options?.showStatic ?? true;
47
+ const onStallChange = options?.onStallChange;
48
+ let rafId = null;
49
+ let destroyed = false;
50
+ const video = document.createElement("video");
51
+ video.muted = true;
52
+ video.playsInline = true;
53
+ /*
54
+ * Frame-stall tracking via requestVideoFrameCallback (per decoded frame).
55
+ * lastFrameTs = 0 on (re)attach, so a freshly connected track shows the
56
+ * full-static "waiting" look until its first real frame decodes. Where
57
+ * rVFC is unsupported (jsdom, old engines) stall detection is disabled
58
+ * and behaviour is unchanged.
59
+ */
60
+ const videoVfc = video;
61
+ const vfcSupported = typeof videoVfc.requestVideoFrameCallback === "function";
62
+ let lastFrameTs = 0;
63
+ let vfcId = null;
64
+ const armFrameCallback = () => {
65
+ if (destroyed || !vfcSupported)
66
+ return;
67
+ vfcId = videoVfc.requestVideoFrameCallback(() => {
68
+ lastFrameTs = performance.now();
69
+ armFrameCallback();
70
+ });
71
+ };
72
+ /*
73
+ * Stall presentation state. stallStartTs anchors the static ramp;
74
+ * lastFrameCanvas holds a copy of the final decoded frame so each stalled
75
+ * draw can restore it pristine before compositing the ramping scrim and
76
+ * static (painting onto an unrestored canvas would accumulate to black
77
+ * within a few frames regardless of the ramp position).
78
+ */
79
+ let stallStartTs = null;
80
+ let stallHasFrame = false;
81
+ let lastFrameCanvas = null;
82
+ let lastFrameCtx = null;
83
+ const snapshotLastFrame = () => {
84
+ if (!lastFrameCanvas) {
85
+ lastFrameCanvas = document.createElement("canvas");
86
+ lastFrameCtx = lastFrameCanvas.getContext("2d");
87
+ }
88
+ if (!lastFrameCtx)
89
+ return false;
90
+ lastFrameCanvas.width = canvas.width;
91
+ lastFrameCanvas.height = canvas.height;
92
+ lastFrameCtx.drawImage(canvas, 0, 0);
93
+ return true;
94
+ };
95
+ const setStalled = (next, now) => {
96
+ if (next === (stallStartTs !== null))
97
+ return;
98
+ if (next) {
99
+ stallStartTs = now;
100
+ stallHasFrame = lastFrameTs > 0 && snapshotLastFrame();
101
+ }
102
+ else {
103
+ stallStartTs = null;
104
+ stallHasFrame = false;
105
+ }
106
+ onStallChange?.(next);
107
+ };
108
+ const onMeta = () => {
109
+ const vw = video.videoWidth;
110
+ const vh = video.videoHeight;
111
+ if (!vw || !vh)
112
+ return;
113
+ canvas.width = vw;
114
+ canvas.height = vh;
115
+ const scale = Math.min(1, NOISE_MAX_W / vw, NOISE_MAX_H / vh);
116
+ noiseCanvas.width = Math.max(1, Math.round(vw * scale));
117
+ noiseCanvas.height = Math.max(1, Math.round(vh * scale));
118
+ };
119
+ video.addEventListener("loadedmetadata", onMeta);
120
+ const setSource = (stream) => {
121
+ if (destroyed)
122
+ return;
123
+ // Stall state belongs to the outgoing source; a swap starts clean.
124
+ setStalled(false, performance.now());
125
+ if (stream) {
126
+ video.srcObject = stream;
127
+ lastFrameTs = 0; // full static until the first frame actually decodes
128
+ armFrameCallback();
129
+ void video.play();
130
+ }
131
+ else {
132
+ // Sourceless: detach the video so draw() composites pure static. Reset
133
+ // the canvas to the placeholder size so a stale source's dimensions
134
+ // don't linger.
135
+ video.pause();
136
+ video.srcObject = null;
137
+ canvas.width = NOISE_MAX_W;
138
+ canvas.height = NOISE_MAX_H;
139
+ noiseCanvas.width = Math.ceil(NOISE_MAX_W / 2);
140
+ noiseCanvas.height = Math.ceil(NOISE_MAX_H / 2);
141
+ }
142
+ };
143
+ setSource(rawStream);
144
+ const draw = () => {
145
+ if (destroyed)
146
+ return;
147
+ const now = performance.now();
148
+ const cw = canvas.width;
149
+ const ch = canvas.height;
150
+ const nw = noiseCanvas.width;
151
+ const nh = noiseCanvas.height;
152
+ // A source that exists but hasn't decoded a frame recently (or yet) is
153
+ // treated as stalled; otherwise the decoder's starved output
154
+ // (macroblock smear) leaks through as a second static style.
155
+ const stalled = vfcSupported &&
156
+ video.srcObject !== null &&
157
+ now - lastFrameTs > SOURCE_STALL_MS;
158
+ setStalled(stalled, now);
159
+ /*
160
+ * Static strength and layer opacity for this frame. A delivering feed
161
+ * draws degrade-driven static at full layer opacity; a stall pins the
162
+ * static to full strength but ramps the LAYER in over the held last
163
+ * frame, so degradation eases in while recovery (the first decoded
164
+ * frame) is instant.
165
+ */
166
+ let eff = intensity;
167
+ let layerAlpha = 1;
168
+ if (video.srcObject && !stalled && video.readyState >= 2) {
169
+ ctx.drawImage(video, 0, 0, cw, ch);
170
+ }
171
+ else if (!video.srcObject) {
172
+ // Sourceless: clear to black so static composites over a blank field
173
+ // rather than the last live frame.
174
+ ctx.fillStyle = "#000";
175
+ ctx.fillRect(0, 0, cw, ch);
176
+ if (!showStatic) {
177
+ rafId = requestAnimationFrame(draw);
178
+ return;
179
+ }
180
+ }
181
+ else if (stalled) {
182
+ // Stall pins the noise to full strength; the handle's degrade-driven
183
+ // intensity (possibly near zero) belongs to a feed that is delivering.
184
+ eff = 1;
185
+ if (stallHasFrame && lastFrameCanvas) {
186
+ // Restore the held last frame, then ramp a black scrim in step with
187
+ // the static so the fully-ramped look matches the sourceless one.
188
+ ctx.drawImage(lastFrameCanvas, 0, 0, cw, ch);
189
+ if (!showStatic) {
190
+ // Frozen last frame, no static: the consumer's chrome carries the
191
+ // staleness indicator instead.
192
+ rafId = requestAnimationFrame(draw);
193
+ return;
194
+ }
195
+ layerAlpha = Math.min(1, (now - (stallStartTs ?? now)) / STALL_RAMP_MS);
196
+ ctx.globalAlpha = layerAlpha;
197
+ ctx.fillStyle = "#000";
198
+ ctx.fillRect(0, 0, cw, ch);
199
+ ctx.globalAlpha = 1;
200
+ }
201
+ else {
202
+ // Never decoded a frame (waiting after attach): the established
203
+ // immediate full-static look; nothing to soften from.
204
+ ctx.fillStyle = "#000";
205
+ ctx.fillRect(0, 0, cw, ch);
206
+ if (!showStatic) {
207
+ rafId = requestAnimationFrame(draw);
208
+ return;
209
+ }
210
+ }
211
+ }
212
+ // Build noise at reduced resolution; composited up to full canvas size.
213
+ const imageData = noiseCtx.createImageData(nw, nh);
214
+ const d = imageData.data;
215
+ const dropThreshold = (eff - 0.45) * 0.35;
216
+ const dropAlpha = Math.round(Math.min(eff * 1.8, 1) * 230);
217
+ const speckleAlpha = Math.round(eff * 210);
218
+ for (let row = 0; row < nh; row++) {
219
+ const dropped = eff > 0.45 && Math.random() < dropThreshold;
220
+ for (let col = 0; col < nw; col++) {
221
+ const i = (row * nw + col) * 4;
222
+ if (dropped) {
223
+ d[i] = d[i + 1] = d[i + 2] = 0;
224
+ d[i + 3] = dropAlpha;
225
+ }
226
+ else if (Math.random() < eff * 0.45) {
227
+ const v = Math.floor(Math.random() * 155 + 100);
228
+ d[i] = d[i + 1] = d[i + 2] = v;
229
+ d[i + 3] = speckleAlpha;
230
+ }
231
+ // else: fully transparent (ImageData is zeroed on creation)
232
+ }
233
+ }
234
+ noiseCtx.putImageData(imageData, 0, 0);
235
+ ctx.globalAlpha = layerAlpha;
236
+ ctx.drawImage(noiseCanvas, 0, 0, cw, ch);
237
+ ctx.globalAlpha = 1;
238
+ rafId = requestAnimationFrame(draw);
239
+ };
240
+ rafId = requestAnimationFrame(draw);
241
+ let processedStream;
242
+ try {
243
+ processedStream = canvas.captureStream(30);
244
+ }
245
+ catch {
246
+ destroyed = true;
247
+ if (rafId !== null)
248
+ cancelAnimationFrame(rafId);
249
+ video.removeEventListener("loadedmetadata", onMeta);
250
+ video.pause();
251
+ video.srcObject = null;
252
+ return null;
253
+ }
254
+ return {
255
+ processedStream,
256
+ setIntensity(level) {
257
+ intensity = level;
258
+ },
259
+ setSource,
260
+ setShowStatic(enabled) {
261
+ showStatic = enabled;
262
+ },
263
+ destroy() {
264
+ destroyed = true;
265
+ if (rafId !== null)
266
+ cancelAnimationFrame(rafId);
267
+ if (vfcId !== null)
268
+ videoVfc.cancelVideoFrameCallback?.(vfcId);
269
+ video.removeEventListener("loadedmetadata", onMeta);
270
+ video.pause();
271
+ video.srcObject = null;
272
+ },
273
+ };
274
+ }
@@ -0,0 +1,190 @@
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
+ * Command sink that the controller writes to. `KerbcastCameraHandle`
36
+ * satisfies this interface structurally -- no adapter needed.
37
+ */
38
+ export interface PanZoomCommandSink {
39
+ setPan(yaw: number, pitch: number): Promise<void> | void;
40
+ setPanRate(yawRate: number, pitchRate: number): Promise<void> | void;
41
+ setFov(fov: number): Promise<void> | void;
42
+ setZoomRate(rate: number): Promise<void> | void;
43
+ }
44
+ /** Camera FoV and pan bounds for clamping. */
45
+ export interface PanZoomBounds {
46
+ fovMin: number;
47
+ fovMax: number;
48
+ panYawMin: number;
49
+ panYawMax: number;
50
+ panPitchMin: number;
51
+ panPitchMax: number;
52
+ }
53
+ /** Tuning knobs for {@link PanZoomController}. All have safe defaults. */
54
+ export interface PanZoomControllerOptions {
55
+ /**
56
+ * Degrees moved per discrete pan nudge (arrow buttons, keyboard).
57
+ * Default: 5.
58
+ */
59
+ panNudgeDeg?: number;
60
+ /**
61
+ * Degrees moved per discrete zoom nudge (zoom buttons, keyboard).
62
+ * Default: 5.
63
+ */
64
+ fovNudgeDeg?: number;
65
+ /**
66
+ * Debounce window (ms) for the FoV slider. Only the settled value is
67
+ * sent; intermediate drag positions do not stream commands.
68
+ * Default: 120.
69
+ */
70
+ fovSliderDebounceMs?: number;
71
+ /**
72
+ * Magnitude below which an analog axis is snapped to 0. Prevents tiny
73
+ * dithering near centre from emitting a stream of non-zero rate commands.
74
+ * Default: 0.05.
75
+ */
76
+ analogDeadzone?: number;
77
+ }
78
+ /**
79
+ * Headless pan/zoom state machine. Manages rate deduplication, analog
80
+ * deadzone, optimistic accumulators, FoV slider debounce, and echo-sync
81
+ * idle rules. Mirrors the behaviour of the CameraFeed component in gonogo
82
+ * (CameraFeed.tsx lines 323-592) without any React or DOM coupling.
83
+ */
84
+ export declare class PanZoomController {
85
+ private readonly sink;
86
+ private readonly panNudgeDeg;
87
+ private readonly fovNudgeDeg;
88
+ private readonly fovSliderDebounceMs;
89
+ private readonly analogDeadzone;
90
+ private sentPanRate;
91
+ private sentZoomRate;
92
+ private localPan;
93
+ private localFov;
94
+ private _sliderFov;
95
+ private sliderDragging;
96
+ private fovDebounceTimer;
97
+ private pendingFov;
98
+ private ballDragging;
99
+ private bounds;
100
+ private readonly sliderFovListeners;
101
+ constructor(sink: PanZoomCommandSink, opts?: PanZoomControllerOptions);
102
+ /**
103
+ * Optimistic FoV value tracked by the slider. Follows the pointer while
104
+ * dragging, then the camera echo when idle. Reactive via
105
+ * {@link onSliderFov}.
106
+ */
107
+ get sliderFov(): number;
108
+ /**
109
+ * Sync from the camera's echoed state (call from the camera's `"change"`
110
+ * event listener). Applies the echoed pan to the pan accumulator only when
111
+ * the controller is pan-idle (no active pan rate, no active ball drag).
112
+ * Applies the echoed FoV to the accumulator and slider only when the
113
+ * controller is zoom-idle (zoom rate 0, slider not dragging, no pending
114
+ * debounced send). Mirrors CameraFeed.tsx:370-387.
115
+ */
116
+ syncFromState(state: {
117
+ fov: number;
118
+ panYaw: number;
119
+ panPitch: number;
120
+ } & PanZoomBounds): void;
121
+ /**
122
+ * Set a persistent normalised pan velocity (-1..1 per axis). Clamps,
123
+ * applies the analog deadzone, and deduplicates against the last-sent
124
+ * value before forwarding to the sink. Mirrors sendPanRate.
125
+ */
126
+ setPanRate(yaw: number, pitch: number): void;
127
+ /**
128
+ * Update one pan axis, preserving the other. Allows two independent inputs
129
+ * (e.g. a serial stick axis and the ball) to compose rather than clobber
130
+ * each other. Mirrors setPanAxis.
131
+ */
132
+ setPanAxis(axis: "yaw" | "pitch", value: number): void;
133
+ /**
134
+ * Set a persistent normalised zoom velocity (-1..1; +1 = zoom in, FoV
135
+ * decreasing). Clamps, applies deadzone, deduplicates. Mirrors sendZoomRate.
136
+ */
137
+ setZoomRate(rate: number): void;
138
+ /**
139
+ * Discrete pan step (+1/-1 per axis sign). Moves the pan accumulator by
140
+ * `panNudgeDeg`, clamped to bounds, then sends an absolute `setPan`.
141
+ * Mirrors nudgePan.
142
+ */
143
+ nudgePan(yawSign: number, pitchSign: number): void;
144
+ /**
145
+ * Discrete FoV step. `deltaSign: -1` = zoom in (FoV decreases by
146
+ * `fovNudgeDeg`), `+1` = zoom out (FoV increases). Clamped to fov bounds.
147
+ * Mirrors nudgeZoom (which calls onFovChange internally).
148
+ */
149
+ nudgeZoom(deltaSign: number): void;
150
+ /**
151
+ * Update the optimistic slider value and schedule a debounced `setFov`.
152
+ * The settled (paused) value is what gets sent; rapid drag input does not
153
+ * stream a command per pixel. Mirrors scheduleFovSlider.
154
+ */
155
+ fovSliderInput(fov: number): void;
156
+ /**
157
+ * Declare slider drag start/end. While dragging, echo-sync does not
158
+ * override the slider (the user is in control). Setting `false` flushes
159
+ * any pending debounced FoV immediately. Mirrors the pointer-up
160
+ * flushFovSlider call.
161
+ */
162
+ setFovSliderDragging(dragging: boolean): void;
163
+ /**
164
+ * Subscribe to slider FoV changes. The callback fires on `fovSliderInput`
165
+ * and on idle echo-sync (when the camera echo updates the slider). Returns
166
+ * an unsubscribe function.
167
+ */
168
+ onSliderFov(cb: (fov: number) => void): () => void;
169
+ /**
170
+ * Declare ball-drag start/end. While dragging, echo-sync does not apply
171
+ * the echoed pan to the local accumulator (the user is in control).
172
+ * The pixel-deflection-to-rate conversion happens in the UI layer; the
173
+ * UI calls `setPanRate` with the normalised result.
174
+ */
175
+ setBallDragging(dragging: boolean): void;
176
+ /**
177
+ * Send zero pan and zoom rates if (and only if) a non-zero rate is currently
178
+ * active, and clear drag flags and any pending FoV timer without sending it.
179
+ * Use on component unmount or camera hide -- mirrors the cleanup effects at
180
+ * CameraFeed.tsx:477-508.
181
+ */
182
+ stop(): void;
183
+ /**
184
+ * Stop the controller and drop all slider-change subscribers. Call when
185
+ * the owning component is permanently destroyed.
186
+ */
187
+ dispose(): void;
188
+ private flushFovSlider;
189
+ private setSliderFovInternal;
190
+ }