@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/src/noise.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
const NOISE_MAX_W = 320;
|
|
2
|
+
const NOISE_MAX_H = 180;
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* A live source that hasn't presented a decoded frame for this long is
|
|
6
|
+
* treated as stalled: the pipeline composites the same black + full-static
|
|
7
|
+
* look it uses when sourceless. Without this, a starved or mid-GOP WebRTC
|
|
8
|
+
* track shows the BROWSER DECODER's output — grey-green macroblock smear —
|
|
9
|
+
* which reads as a second, inconsistent "static" style. Long enough that a
|
|
10
|
+
* single late frame at 30 fps never flickers the overlay.
|
|
11
|
+
*/
|
|
12
|
+
const SOURCE_STALL_MS = 500;
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* When a source that WAS delivering frames stalls, the full-static look
|
|
16
|
+
* ramps in over this window on top of the held last frame instead of
|
|
17
|
+
* cutting hard. Only degradation is softened: the first decoded frame
|
|
18
|
+
* drops the static outright, so recovery feels instant. Sources that
|
|
19
|
+
* never decoded a frame (fresh attach) keep the immediate full-static
|
|
20
|
+
* "waiting" look: there is no last frame to soften from.
|
|
21
|
+
*/
|
|
22
|
+
const STALL_RAMP_MS = 5000;
|
|
23
|
+
|
|
24
|
+
export interface NoisePipelineOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Composite animated static over stalled and sourceless states (default
|
|
27
|
+
* true). When false: a stalled source holds its last decoded frame with no
|
|
28
|
+
* static (the consumer provides its own staleness chrome), and a sourceless
|
|
29
|
+
* path clears to black without noise (only the text overlay renders).
|
|
30
|
+
*/
|
|
31
|
+
showStatic?: boolean;
|
|
32
|
+
/** Observe stall transitions (frames stopped / frames resumed). */
|
|
33
|
+
onStallChange?: (stalled: boolean) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NoisePipeline {
|
|
37
|
+
readonly processedStream: MediaStream;
|
|
38
|
+
setIntensity(level: number): void;
|
|
39
|
+
/**
|
|
40
|
+
* Swap (or remove) the source video. With a non-null stream the pipeline
|
|
41
|
+
* composites static over the live frame; with `null` it goes *sourceless*
|
|
42
|
+
* — `draw()` skips `drawImage` and renders pure static. The output
|
|
43
|
+
* `processedStream` stays valid across the swap, so consumers never see a
|
|
44
|
+
* gap.
|
|
45
|
+
*/
|
|
46
|
+
setSource(stream: MediaStream | null): void;
|
|
47
|
+
/** Runtime switch for {@link NoisePipelineOptions.showStatic}. */
|
|
48
|
+
setShowStatic(enabled: boolean): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wraps a (possibly absent) `MediaStream` in a canvas pipeline that
|
|
54
|
+
* composites digital static over the video. Pass `null` to run the pipeline
|
|
55
|
+
* sourceless — it emits pure static until a source is attached via
|
|
56
|
+
* {@link NoisePipeline.setSource}. Returns null when `captureStream` is
|
|
57
|
+
* unavailable (SSR, test environments without canvas support).
|
|
58
|
+
*/
|
|
59
|
+
export function tryCreateNoisePipeline(
|
|
60
|
+
rawStream: MediaStream | null,
|
|
61
|
+
initialIntensity: number,
|
|
62
|
+
options?: NoisePipelineOptions,
|
|
63
|
+
): NoisePipeline | null {
|
|
64
|
+
const canvas = document.createElement("canvas");
|
|
65
|
+
if (typeof (canvas as HTMLCanvasElement & { captureStream?: unknown }).captureStream !== "function") {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const noiseCanvas = document.createElement("canvas");
|
|
70
|
+
const noiseCtx = noiseCanvas.getContext("2d");
|
|
71
|
+
const ctx = canvas.getContext("2d");
|
|
72
|
+
if (!noiseCtx || !ctx) return null;
|
|
73
|
+
|
|
74
|
+
// Size the output canvas to video dimensions once metadata loads.
|
|
75
|
+
// Until then (and while sourceless), use a placeholder so captureStream
|
|
76
|
+
// has something to output.
|
|
77
|
+
canvas.width = NOISE_MAX_W;
|
|
78
|
+
canvas.height = NOISE_MAX_H;
|
|
79
|
+
noiseCanvas.width = Math.ceil(NOISE_MAX_W / 2);
|
|
80
|
+
noiseCanvas.height = Math.ceil(NOISE_MAX_H / 2);
|
|
81
|
+
|
|
82
|
+
let intensity = initialIntensity;
|
|
83
|
+
let showStatic = options?.showStatic ?? true;
|
|
84
|
+
const onStallChange = options?.onStallChange;
|
|
85
|
+
let rafId: number | null = null;
|
|
86
|
+
let destroyed = false;
|
|
87
|
+
|
|
88
|
+
const video = document.createElement("video");
|
|
89
|
+
video.muted = true;
|
|
90
|
+
video.playsInline = true;
|
|
91
|
+
|
|
92
|
+
/*
|
|
93
|
+
* Frame-stall tracking via requestVideoFrameCallback (per decoded frame).
|
|
94
|
+
* lastFrameTs = 0 on (re)attach, so a freshly connected track shows the
|
|
95
|
+
* full-static "waiting" look until its first real frame decodes. Where
|
|
96
|
+
* rVFC is unsupported (jsdom, old engines) stall detection is disabled
|
|
97
|
+
* and behaviour is unchanged.
|
|
98
|
+
*/
|
|
99
|
+
const videoVfc = video as HTMLVideoElement & {
|
|
100
|
+
requestVideoFrameCallback?: (cb: () => void) => number;
|
|
101
|
+
cancelVideoFrameCallback?: (id: number) => void;
|
|
102
|
+
};
|
|
103
|
+
const vfcSupported = typeof videoVfc.requestVideoFrameCallback === "function";
|
|
104
|
+
let lastFrameTs = 0;
|
|
105
|
+
let vfcId: number | null = null;
|
|
106
|
+
const armFrameCallback = () => {
|
|
107
|
+
if (destroyed || !vfcSupported) return;
|
|
108
|
+
vfcId = videoVfc.requestVideoFrameCallback!(() => {
|
|
109
|
+
lastFrameTs = performance.now();
|
|
110
|
+
armFrameCallback();
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/*
|
|
115
|
+
* Stall presentation state. stallStartTs anchors the static ramp;
|
|
116
|
+
* lastFrameCanvas holds a copy of the final decoded frame so each stalled
|
|
117
|
+
* draw can restore it pristine before compositing the ramping scrim and
|
|
118
|
+
* static (painting onto an unrestored canvas would accumulate to black
|
|
119
|
+
* within a few frames regardless of the ramp position).
|
|
120
|
+
*/
|
|
121
|
+
let stallStartTs: number | null = null;
|
|
122
|
+
let stallHasFrame = false;
|
|
123
|
+
let lastFrameCanvas: HTMLCanvasElement | null = null;
|
|
124
|
+
let lastFrameCtx: CanvasRenderingContext2D | null = null;
|
|
125
|
+
|
|
126
|
+
const snapshotLastFrame = (): boolean => {
|
|
127
|
+
if (!lastFrameCanvas) {
|
|
128
|
+
lastFrameCanvas = document.createElement("canvas");
|
|
129
|
+
lastFrameCtx = lastFrameCanvas.getContext("2d");
|
|
130
|
+
}
|
|
131
|
+
if (!lastFrameCtx) return false;
|
|
132
|
+
lastFrameCanvas.width = canvas.width;
|
|
133
|
+
lastFrameCanvas.height = canvas.height;
|
|
134
|
+
lastFrameCtx.drawImage(canvas, 0, 0);
|
|
135
|
+
return true;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const setStalled = (next: boolean, now: number) => {
|
|
139
|
+
if (next === (stallStartTs !== null)) return;
|
|
140
|
+
if (next) {
|
|
141
|
+
stallStartTs = now;
|
|
142
|
+
stallHasFrame = lastFrameTs > 0 && snapshotLastFrame();
|
|
143
|
+
} else {
|
|
144
|
+
stallStartTs = null;
|
|
145
|
+
stallHasFrame = false;
|
|
146
|
+
}
|
|
147
|
+
onStallChange?.(next);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const onMeta = () => {
|
|
151
|
+
const vw = video.videoWidth;
|
|
152
|
+
const vh = video.videoHeight;
|
|
153
|
+
if (!vw || !vh) return;
|
|
154
|
+
canvas.width = vw;
|
|
155
|
+
canvas.height = vh;
|
|
156
|
+
const scale = Math.min(1, NOISE_MAX_W / vw, NOISE_MAX_H / vh);
|
|
157
|
+
noiseCanvas.width = Math.max(1, Math.round(vw * scale));
|
|
158
|
+
noiseCanvas.height = Math.max(1, Math.round(vh * scale));
|
|
159
|
+
};
|
|
160
|
+
video.addEventListener("loadedmetadata", onMeta);
|
|
161
|
+
|
|
162
|
+
const setSource = (stream: MediaStream | null) => {
|
|
163
|
+
if (destroyed) return;
|
|
164
|
+
// Stall state belongs to the outgoing source; a swap starts clean.
|
|
165
|
+
setStalled(false, performance.now());
|
|
166
|
+
if (stream) {
|
|
167
|
+
video.srcObject = stream;
|
|
168
|
+
lastFrameTs = 0; // full static until the first frame actually decodes
|
|
169
|
+
armFrameCallback();
|
|
170
|
+
void video.play();
|
|
171
|
+
} else {
|
|
172
|
+
// Sourceless: detach the video so draw() composites pure static. Reset
|
|
173
|
+
// the canvas to the placeholder size so a stale source's dimensions
|
|
174
|
+
// don't linger.
|
|
175
|
+
video.pause();
|
|
176
|
+
video.srcObject = null;
|
|
177
|
+
canvas.width = NOISE_MAX_W;
|
|
178
|
+
canvas.height = NOISE_MAX_H;
|
|
179
|
+
noiseCanvas.width = Math.ceil(NOISE_MAX_W / 2);
|
|
180
|
+
noiseCanvas.height = Math.ceil(NOISE_MAX_H / 2);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
setSource(rawStream);
|
|
184
|
+
|
|
185
|
+
const draw = () => {
|
|
186
|
+
if (destroyed) return;
|
|
187
|
+
const now = performance.now();
|
|
188
|
+
const cw = canvas.width;
|
|
189
|
+
const ch = canvas.height;
|
|
190
|
+
const nw = noiseCanvas.width;
|
|
191
|
+
const nh = noiseCanvas.height;
|
|
192
|
+
|
|
193
|
+
// A source that exists but hasn't decoded a frame recently (or yet) is
|
|
194
|
+
// treated as stalled; otherwise the decoder's starved output
|
|
195
|
+
// (macroblock smear) leaks through as a second static style.
|
|
196
|
+
const stalled =
|
|
197
|
+
vfcSupported &&
|
|
198
|
+
video.srcObject !== null &&
|
|
199
|
+
now - lastFrameTs > SOURCE_STALL_MS;
|
|
200
|
+
setStalled(stalled, now);
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* Static strength and layer opacity for this frame. A delivering feed
|
|
204
|
+
* draws degrade-driven static at full layer opacity; a stall pins the
|
|
205
|
+
* static to full strength but ramps the LAYER in over the held last
|
|
206
|
+
* frame, so degradation eases in while recovery (the first decoded
|
|
207
|
+
* frame) is instant.
|
|
208
|
+
*/
|
|
209
|
+
let eff = intensity;
|
|
210
|
+
let layerAlpha = 1;
|
|
211
|
+
|
|
212
|
+
if (video.srcObject && !stalled && video.readyState >= 2) {
|
|
213
|
+
ctx.drawImage(video, 0, 0, cw, ch);
|
|
214
|
+
} else if (!video.srcObject) {
|
|
215
|
+
// Sourceless: clear to black so static composites over a blank field
|
|
216
|
+
// rather than the last live frame.
|
|
217
|
+
ctx.fillStyle = "#000";
|
|
218
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
219
|
+
if (!showStatic) {
|
|
220
|
+
rafId = requestAnimationFrame(draw);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
} else if (stalled) {
|
|
224
|
+
// Stall pins the noise to full strength; the handle's degrade-driven
|
|
225
|
+
// intensity (possibly near zero) belongs to a feed that is delivering.
|
|
226
|
+
eff = 1;
|
|
227
|
+
if (stallHasFrame && lastFrameCanvas) {
|
|
228
|
+
// Restore the held last frame, then ramp a black scrim in step with
|
|
229
|
+
// the static so the fully-ramped look matches the sourceless one.
|
|
230
|
+
ctx.drawImage(lastFrameCanvas, 0, 0, cw, ch);
|
|
231
|
+
if (!showStatic) {
|
|
232
|
+
// Frozen last frame, no static: the consumer's chrome carries the
|
|
233
|
+
// staleness indicator instead.
|
|
234
|
+
rafId = requestAnimationFrame(draw);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
layerAlpha = Math.min(1, (now - (stallStartTs ?? now)) / STALL_RAMP_MS);
|
|
238
|
+
ctx.globalAlpha = layerAlpha;
|
|
239
|
+
ctx.fillStyle = "#000";
|
|
240
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
241
|
+
ctx.globalAlpha = 1;
|
|
242
|
+
} else {
|
|
243
|
+
// Never decoded a frame (waiting after attach): the established
|
|
244
|
+
// immediate full-static look; nothing to soften from.
|
|
245
|
+
ctx.fillStyle = "#000";
|
|
246
|
+
ctx.fillRect(0, 0, cw, ch);
|
|
247
|
+
if (!showStatic) {
|
|
248
|
+
rafId = requestAnimationFrame(draw);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Build noise at reduced resolution; composited up to full canvas size.
|
|
255
|
+
const imageData = noiseCtx.createImageData(nw, nh);
|
|
256
|
+
const d = imageData.data;
|
|
257
|
+
const dropThreshold = (eff - 0.45) * 0.35;
|
|
258
|
+
const dropAlpha = Math.round(Math.min(eff * 1.8, 1) * 230);
|
|
259
|
+
const speckleAlpha = Math.round(eff * 210);
|
|
260
|
+
|
|
261
|
+
for (let row = 0; row < nh; row++) {
|
|
262
|
+
const dropped = eff > 0.45 && Math.random() < dropThreshold;
|
|
263
|
+
for (let col = 0; col < nw; col++) {
|
|
264
|
+
const i = (row * nw + col) * 4;
|
|
265
|
+
if (dropped) {
|
|
266
|
+
d[i] = d[i + 1] = d[i + 2] = 0;
|
|
267
|
+
d[i + 3] = dropAlpha;
|
|
268
|
+
} else if (Math.random() < eff * 0.45) {
|
|
269
|
+
const v = Math.floor(Math.random() * 155 + 100);
|
|
270
|
+
d[i] = d[i + 1] = d[i + 2] = v;
|
|
271
|
+
d[i + 3] = speckleAlpha;
|
|
272
|
+
}
|
|
273
|
+
// else: fully transparent (ImageData is zeroed on creation)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
noiseCtx.putImageData(imageData, 0, 0);
|
|
277
|
+
ctx.globalAlpha = layerAlpha;
|
|
278
|
+
ctx.drawImage(noiseCanvas, 0, 0, cw, ch);
|
|
279
|
+
ctx.globalAlpha = 1;
|
|
280
|
+
|
|
281
|
+
rafId = requestAnimationFrame(draw);
|
|
282
|
+
};
|
|
283
|
+
rafId = requestAnimationFrame(draw);
|
|
284
|
+
|
|
285
|
+
let processedStream: MediaStream;
|
|
286
|
+
try {
|
|
287
|
+
processedStream = (
|
|
288
|
+
canvas as HTMLCanvasElement & { captureStream(fps: number): MediaStream }
|
|
289
|
+
).captureStream(30);
|
|
290
|
+
} catch {
|
|
291
|
+
destroyed = true;
|
|
292
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
293
|
+
video.removeEventListener("loadedmetadata", onMeta);
|
|
294
|
+
video.pause();
|
|
295
|
+
video.srcObject = null;
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
processedStream,
|
|
301
|
+
setIntensity(level: number) {
|
|
302
|
+
intensity = level;
|
|
303
|
+
},
|
|
304
|
+
setSource,
|
|
305
|
+
setShowStatic(enabled: boolean) {
|
|
306
|
+
showStatic = enabled;
|
|
307
|
+
},
|
|
308
|
+
destroy() {
|
|
309
|
+
destroyed = true;
|
|
310
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
311
|
+
if (vfcId !== null) videoVfc.cancelVideoFrameCallback?.(vfcId);
|
|
312
|
+
video.removeEventListener("loadedmetadata", onMeta);
|
|
313
|
+
video.pause();
|
|
314
|
+
video.srcObject = null;
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|