@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.
- package/CHANGELOG.md +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/noise.d.ts
ADDED
|
@@ -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
|
+
}
|