@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/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
+ }