@stream-io/video-filters-web 0.3.0 → 0.5.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +27 -24
  3. package/dist/index.cjs.js +1099 -6
  4. package/dist/index.cjs.js.map +1 -1
  5. package/dist/index.d.ts +6 -3
  6. package/dist/index.es.js +1096 -7
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/FallbackGenerator.d.ts +17 -0
  9. package/dist/src/FallbackProcessor.d.ts +38 -0
  10. package/dist/src/VirtualBackground.d.ts +42 -0
  11. package/dist/src/WebGLRenderer.d.ts +80 -0
  12. package/dist/src/compatibility.d.ts +5 -0
  13. package/dist/src/{createRenderer.d.ts → legacy/createRenderer.d.ts} +1 -2
  14. package/dist/src/{webgl2 → legacy/webgl2}/backgroundBlurStage.d.ts +1 -1
  15. package/dist/src/{webgl2 → legacy/webgl2}/webgl2Pipeline.d.ts +1 -1
  16. package/dist/src/mediapipe.d.ts +4 -0
  17. package/dist/src/types.d.ts +45 -0
  18. package/index.ts +6 -3
  19. package/mediapipe/models/selfie_segmenter.tflite +0 -0
  20. package/mediapipe/wasm/vision_wasm_internal.js +20 -0
  21. package/mediapipe/wasm/vision_wasm_internal.wasm +0 -0
  22. package/mediapipe/wasm/vision_wasm_nosimd_internal.js +20 -0
  23. package/mediapipe/wasm/vision_wasm_nosimd_internal.wasm +0 -0
  24. package/package.json +5 -2
  25. package/src/FallbackGenerator.ts +90 -0
  26. package/src/FallbackProcessor.ts +132 -0
  27. package/src/VirtualBackground.ts +276 -0
  28. package/src/WebGLRenderer.ts +1187 -0
  29. package/src/compatibility.ts +22 -0
  30. package/src/{createRenderer.ts → legacy/createRenderer.ts} +1 -2
  31. package/src/{tflite.ts → legacy/tflite.ts} +1 -1
  32. package/src/{webgl2 → legacy/webgl2}/backgroundBlurStage.ts +1 -1
  33. package/src/{webgl2 → legacy/webgl2}/webgl2Pipeline.ts +1 -1
  34. package/src/mediapipe.ts +25 -0
  35. package/src/types.ts +62 -0
  36. /package/dist/src/{helpers → legacy/helpers}/webglHelper.d.ts +0 -0
  37. /package/dist/src/{segmentation.d.ts → legacy/segmentation.d.ts} +0 -0
  38. /package/dist/src/{tflite.d.ts → legacy/tflite.d.ts} +0 -0
  39. /package/dist/src/{webgl2 → legacy/webgl2}/backgroundImageStage.d.ts +0 -0
  40. /package/dist/src/{webgl2 → legacy/webgl2}/jointBilateralFilterStage.d.ts +0 -0
  41. /package/dist/src/{webgl2 → legacy/webgl2}/resizingStage.d.ts +0 -0
  42. /package/dist/src/{webgl2 → legacy/webgl2}/softmaxStage.d.ts +0 -0
  43. /package/src/{helpers → legacy/helpers}/webglHelper.ts +0 -0
  44. /package/src/{segmentation.ts → legacy/segmentation.ts} +0 -0
  45. /package/src/{tflite-simd.js → legacy/tflite-simd.js} +0 -0
  46. /package/src/{webgl2 → legacy/webgl2}/backgroundImageStage.ts +0 -0
  47. /package/src/{webgl2 → legacy/webgl2}/jointBilateralFilterStage.ts +0 -0
  48. /package/src/{webgl2 → legacy/webgl2}/resizingStage.ts +0 -0
  49. /package/src/{webgl2 → legacy/webgl2}/softmaxStage.ts +0 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Type representing a video track generator that can be either the native
3
+ * MediaStreamTrackGenerator or the fallback implementation.
4
+ */
5
+ export interface MediaStreamTrackGenerator<T> extends MediaStreamTrack {
6
+ writable: WritableStream<T>;
7
+ }
8
+
9
+ /**
10
+ * Configuration options for creating a track generator.
11
+ */
12
+ export interface TrackGeneratorOptions {
13
+ readonly kind: 'video';
14
+ readonly signalTarget?: MediaStreamTrack;
15
+ }
16
+
17
+ /**
18
+ * Fallback video processor for browsers that do not support MediaStreamTrackGenerator.
19
+ *
20
+ * Produces a video MediaStreamTrack sourced from a canvas and exposes
21
+ * a WritableStream<VideoFrame> on track.writable for writing frames.
22
+ */
23
+ class FallbackGenerator {
24
+ constructor({ kind, signalTarget }: TrackGeneratorOptions) {
25
+ if (kind !== 'video') {
26
+ throw new Error('Only video tracks are supported');
27
+ }
28
+
29
+ const canvas = document.createElement('canvas');
30
+ const ctx = canvas.getContext('2d', { desynchronized: true });
31
+ if (!ctx) {
32
+ throw new Error('Failed to get 2D context from canvas');
33
+ }
34
+
35
+ const mediaStream = canvas.captureStream();
36
+ const track =
37
+ mediaStream.getVideoTracks()[0] as MediaStreamTrackGenerator<VideoFrame>;
38
+
39
+ const height = signalTarget?.getSettings().height;
40
+ const width = signalTarget?.getSettings().width;
41
+ if (height && width) {
42
+ canvas.height = height;
43
+ canvas.width = width;
44
+ }
45
+
46
+ if (!track) {
47
+ throw new Error('Failed to create canvas track');
48
+ }
49
+
50
+ if (signalTarget) {
51
+ signalTarget.addEventListener('ended', () => {
52
+ track.stop();
53
+ });
54
+ }
55
+
56
+ track.writable = new WritableStream({
57
+ write: (frame: VideoFrame) => {
58
+ if (
59
+ canvas.width !== frame.displayWidth ||
60
+ canvas.height !== frame.displayHeight
61
+ ) {
62
+ canvas.width = frame.displayWidth;
63
+ canvas.height = frame.displayHeight;
64
+ }
65
+
66
+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
67
+ frame.close();
68
+ },
69
+ abort: () => {
70
+ track.stop();
71
+ },
72
+ close: () => {
73
+ track.stop();
74
+ },
75
+ });
76
+
77
+ return track as MediaStreamTrackGenerator<VideoFrame>;
78
+ }
79
+ }
80
+
81
+ type VideoTrackGeneratorConstructor = new (
82
+ options: TrackGeneratorOptions,
83
+ ) => MediaStreamTrackGenerator<VideoFrame>;
84
+
85
+ const TrackGenerator: VideoTrackGeneratorConstructor =
86
+ typeof MediaStreamTrackGenerator !== 'undefined'
87
+ ? (MediaStreamTrackGenerator as unknown as VideoTrackGeneratorConstructor)
88
+ : (FallbackGenerator as unknown as VideoTrackGeneratorConstructor);
89
+
90
+ export { TrackGenerator };
@@ -0,0 +1,132 @@
1
+ import { WorkerTimer } from '@stream-io/worker-timer';
2
+
3
+ /**
4
+ * Representing a video track processor that can be either the native
5
+ * MediaStreamTrackProcessor or the fallback implementation.
6
+ */
7
+ export interface MediaStreamTrackProcessor<T> {
8
+ readonly readable: ReadableStream<T>;
9
+ }
10
+
11
+ /**
12
+ * Configuration options for creating a track processor.
13
+ */
14
+ export interface TrackProcessorOptions {
15
+ readonly track: MediaStreamTrack;
16
+ }
17
+
18
+ /**
19
+ * Fallback implementation for browsers without MediaStreamTrackGenerator.
20
+ *
21
+ * Produces a video MediaStreamTrack sourced from a canvas and exposes a
22
+ * WritableStream<VideoFrame> on track.writable. Written frames are drawn
23
+ * into the canvas and update the underlying track automatically.
24
+ */
25
+ class FallbackProcessor implements MediaStreamTrackProcessor<VideoFrame> {
26
+ readonly readable: ReadableStream<VideoFrame>;
27
+
28
+ readonly workerTimer: WorkerTimer;
29
+ readonly video: HTMLVideoElement;
30
+
31
+ constructor({ track }: TrackProcessorOptions) {
32
+ if (!track) throw new Error('MediaStreamTrack is required');
33
+ if (track.kind !== 'video') {
34
+ throw new Error('MediaStreamTrack must be video');
35
+ }
36
+ let running = true;
37
+
38
+ this.video = document.createElement('video');
39
+
40
+ this.video.muted = true;
41
+ this.video.playsInline = true;
42
+ this.video.srcObject = new MediaStream([track]);
43
+
44
+ const canvas = new OffscreenCanvas(1, 1);
45
+ const ctx = canvas.getContext('2d');
46
+
47
+ if (!ctx) throw new Error('Failed to get 2D context from OffscreenCanvas');
48
+
49
+ let timestamp = 0;
50
+ const frameRate = track.getSettings().frameRate || 30;
51
+ let frameDuration = 1000 / frameRate;
52
+ let lastVideoTime = -1;
53
+
54
+ this.workerTimer = new WorkerTimer({ useWorker: true });
55
+ this.readable = new ReadableStream({
56
+ start: async () => {
57
+ await Promise.all([
58
+ this.video.play(),
59
+ new Promise((r) =>
60
+ this.video.addEventListener('loadeddata', r, { once: true }),
61
+ ),
62
+ ]);
63
+ frameDuration = 1000 / (track.getSettings().frameRate || 30);
64
+ timestamp = performance.now();
65
+ },
66
+ pull: async (controller) => {
67
+ if (!running) {
68
+ controller.close();
69
+ this.close();
70
+ return;
71
+ }
72
+ const delta = performance.now() - timestamp;
73
+ if (delta <= frameDuration) {
74
+ await new Promise((r: (value?: unknown) => void) =>
75
+ this.workerTimer.setTimeout(r, frameDuration - delta),
76
+ );
77
+ }
78
+ timestamp = performance.now();
79
+
80
+ const currentTime = this.video.currentTime;
81
+ const hasNewFrame = currentTime !== lastVideoTime;
82
+
83
+ if (!hasNewFrame) {
84
+ await new Promise((r: (value?: unknown) => void) =>
85
+ this.workerTimer.setTimeout(r, frameDuration),
86
+ );
87
+ return;
88
+ }
89
+
90
+ lastVideoTime = currentTime;
91
+
92
+ if (
93
+ canvas.width !== this.video.videoWidth ||
94
+ canvas.height !== this.video.videoHeight
95
+ ) {
96
+ canvas.width = this.video.videoWidth;
97
+ canvas.height = this.video.videoHeight;
98
+ }
99
+
100
+ ctx.drawImage(this.video, 0, 0);
101
+
102
+ try {
103
+ const frame = new VideoFrame(canvas, { timestamp });
104
+ controller.enqueue(frame);
105
+ } catch (err) {
106
+ running = false;
107
+ controller.error(err);
108
+ this.close();
109
+ }
110
+ },
111
+ cancel: () => {
112
+ running = false;
113
+ this.close();
114
+ },
115
+ });
116
+ }
117
+
118
+ public close = () => {
119
+ this.video.pause();
120
+ this.video.srcObject = null;
121
+ this.video.src = '';
122
+
123
+ this.workerTimer.destroy();
124
+ };
125
+ }
126
+
127
+ const TrackProcessor =
128
+ typeof MediaStreamTrackProcessor !== 'undefined'
129
+ ? MediaStreamTrackProcessor
130
+ : FallbackProcessor;
131
+
132
+ export { TrackProcessor };
@@ -0,0 +1,276 @@
1
+ import {
2
+ BACKGROUND_BLUR_MAP,
3
+ BackgroundOptions,
4
+ SegmenterOptions,
5
+ VideoTrackProcessorHooks,
6
+ } from './types';
7
+ import { FilesetResolver, ImageSegmenter } from '@mediapipe/tasks-vision';
8
+ import { WebGLRenderer } from './WebGLRenderer';
9
+ import { packageName, version } from './version';
10
+ import { TrackGenerator, MediaStreamTrackGenerator } from './FallbackGenerator';
11
+ import { MediaStreamTrackProcessor, TrackProcessor } from './FallbackProcessor';
12
+
13
+ /**
14
+ * Wraps a video MediaStreamTrack in a real-time processing pipeline.
15
+ * Incoming frames are processed through a transformer and re-emitted
16
+ * on a new MediaStreamVideoTrack for downstream consumption.
17
+ */
18
+ export class VirtualBackground {
19
+ private readonly processor: MediaStreamTrackProcessor<VideoFrame>;
20
+ private readonly generator: MediaStreamTrackGenerator<VideoFrame>;
21
+
22
+ private canvas!: OffscreenCanvas;
23
+ private segmenter: ImageSegmenter | null = null;
24
+ private isSegmenterReady = false;
25
+ private webGlRenderer!: WebGLRenderer;
26
+ private abortController: AbortController;
27
+
28
+ private segmenterDelayTotal = 0;
29
+ private frames = 0;
30
+ private lastStatsTime = 0;
31
+
32
+ constructor(
33
+ private readonly track: MediaStreamVideoTrack,
34
+ private readonly options: BackgroundOptions = {},
35
+ private readonly hooks: VideoTrackProcessorHooks = {},
36
+ ) {
37
+ this.processor = new TrackProcessor({ track });
38
+ this.generator = new TrackGenerator({
39
+ kind: 'video',
40
+ signalTarget: track,
41
+ });
42
+
43
+ this.abortController = new AbortController();
44
+ }
45
+
46
+ public async start(): Promise<MediaStreamTrack> {
47
+ const { onError } = this.hooks;
48
+
49
+ const { readable } = this.processor;
50
+ const { writable } = this.generator;
51
+
52
+ const displayWidth = this.track.getSettings().width ?? 1280;
53
+ const displayHeight = this.track.getSettings().height ?? 720;
54
+
55
+ this.canvas = new OffscreenCanvas(displayWidth, displayHeight);
56
+ this.webGlRenderer = new WebGLRenderer(this.canvas);
57
+
58
+ await this.initializeSegmenter();
59
+
60
+ const opts = await this.initializeSegmenterOptions();
61
+
62
+ const transformStream = new TransformStream<VideoFrame, VideoFrame>({
63
+ transform: async (frame, controller) => {
64
+ try {
65
+ if (this.abortController.signal.aborted) {
66
+ return frame.close();
67
+ }
68
+
69
+ const processed = await this.transform(frame, opts);
70
+ controller.enqueue(processed);
71
+ } catch (e) {
72
+ console.error('[virtual-background] error processing frame:', e);
73
+ this.hooks.onError?.(e);
74
+
75
+ if (!this.abortController.signal.aborted) {
76
+ controller.enqueue(frame);
77
+ }
78
+ } finally {
79
+ frame.close();
80
+ }
81
+ },
82
+ flush: () => {
83
+ if (this.segmenter) {
84
+ this.segmenter.close();
85
+ this.segmenter = null;
86
+ }
87
+ this.isSegmenterReady = false;
88
+ },
89
+ });
90
+
91
+ const signal = this.abortController.signal;
92
+
93
+ readable
94
+ .pipeThrough(transformStream, { signal })
95
+ .pipeTo(writable)
96
+ .catch((e) => {
97
+ if (e.name !== 'AbortError') {
98
+ console.error('[virtual-background] Error processing track:', e);
99
+ onError?.(e);
100
+ }
101
+ });
102
+
103
+ return this.generator;
104
+ }
105
+
106
+ /**
107
+ * Loads and initializes the MediaPipe `ImageSegmenter`.
108
+ */
109
+ private async initializeSegmenter() {
110
+ try {
111
+ const basePath =
112
+ this.options?.basePath ||
113
+ `https://unpkg.com/${packageName}@${version}/mediapipe`;
114
+
115
+ const defaultModelPath = `${basePath}/models/selfie_segmenter.tflite`;
116
+
117
+ const model = this.options?.modelPath || defaultModelPath;
118
+
119
+ const wasmPath = `${basePath}/wasm`;
120
+
121
+ const fileset = await FilesetResolver.forVisionTasks(wasmPath);
122
+
123
+ this.segmenter = await ImageSegmenter.createFromOptions(fileset, {
124
+ baseOptions: {
125
+ modelAssetPath: model,
126
+ delegate: 'GPU',
127
+ },
128
+ runningMode: 'VIDEO',
129
+ outputCategoryMask: true,
130
+ outputConfidenceMasks: true,
131
+ canvas: this.canvas,
132
+ });
133
+
134
+ this.isSegmenterReady = true;
135
+ } catch (error) {
136
+ console.error(
137
+ '[virtual-background] Failed to initialize MediaPipe segmenter:',
138
+ error,
139
+ );
140
+ this.isSegmenterReady = false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Processes a single video frame.
146
+ *
147
+ * Performs segmentation via MediaPipe and then composites the frame
148
+ * through the WebGL renderer to apply background effects.
149
+ *
150
+ * @param frame - The incoming frame from the processor.
151
+ * @param opts - The segmentation options to use.
152
+ *
153
+ * @returns A new `VideoFrame` containing the processed image.
154
+ */
155
+ private async transform(
156
+ frame: VideoFrame,
157
+ opts: SegmenterOptions,
158
+ ): Promise<VideoFrame> {
159
+ if (this.isSegmenterReady && this.segmenter) {
160
+ try {
161
+ const start = performance.now();
162
+ await new Promise<void>((resolve) => {
163
+ this.segmenter!.segmentForVideo(frame, frame.timestamp, (result) => {
164
+ const categoryMask = result.categoryMask!.getAsWebGLTexture();
165
+ const confidenceMask =
166
+ result.confidenceMasks![0].getAsWebGLTexture();
167
+
168
+ this.webGlRenderer.render(
169
+ frame,
170
+ opts,
171
+ categoryMask,
172
+ confidenceMask,
173
+ );
174
+
175
+ const now = performance.now();
176
+ this.segmenterDelayTotal += now - start;
177
+ this.frames++;
178
+
179
+ if (this.lastStatsTime === 0) {
180
+ this.lastStatsTime = now;
181
+ }
182
+
183
+ if (now - this.lastStatsTime > 1000) {
184
+ const delay =
185
+ Math.round((this.segmenterDelayTotal / this.frames) * 100) /
186
+ 100;
187
+ const fps = Math.round(
188
+ (1000 * this.frames) / (now - this.lastStatsTime),
189
+ );
190
+
191
+ this.hooks.onStats?.({ delay, fps, timestamp: now });
192
+
193
+ this.lastStatsTime = now;
194
+ this.segmenterDelayTotal = 0;
195
+ this.frames = 0;
196
+ }
197
+
198
+ resolve();
199
+ });
200
+ });
201
+ } catch (error) {
202
+ console.error('[virtual-background] Error during segmentation:', error);
203
+ }
204
+ }
205
+
206
+ return new VideoFrame(this.canvas, { timestamp: frame.timestamp });
207
+ }
208
+
209
+ private async loadBackground(url: string | undefined) {
210
+ if (!url) {
211
+ return;
212
+ }
213
+
214
+ const response = await fetch(url);
215
+ if (!response.ok) {
216
+ console.error(
217
+ `[virtual-background] Failed to fetch background source ${url} (status: ${response.status})`,
218
+ );
219
+ return;
220
+ }
221
+ const blob = await response.blob();
222
+
223
+ const imageBitmap = await createImageBitmap(blob);
224
+ return { type: 'image', media: imageBitmap, url };
225
+ }
226
+
227
+ private async initializeSegmenterOptions(): Promise<SegmenterOptions> {
228
+ const isSelfieMode = this.options.modelPath
229
+ ? this.options.modelPath?.includes('selfie_segmenter')
230
+ : true;
231
+
232
+ if (this.options.backgroundFilter === 'image') {
233
+ return {
234
+ backgroundSource: await this.loadBackground(
235
+ this.options.backgroundImage,
236
+ ),
237
+ bgBlur: 0,
238
+ bgBlurRadius: 0,
239
+ isSelfieMode,
240
+ };
241
+ }
242
+
243
+ const blurLevel = this.options.backgroundBlurLevel;
244
+ if (typeof blurLevel === 'string') {
245
+ return {
246
+ ...BACKGROUND_BLUR_MAP[blurLevel],
247
+ backgroundSource: undefined,
248
+ isSelfieMode,
249
+ };
250
+ }
251
+
252
+ const numeric = blurLevel ?? 5;
253
+
254
+ const bgBlur = Math.min(numeric * 3, 30);
255
+ const bgBlurRadius = Math.min(numeric, 10);
256
+
257
+ return {
258
+ backgroundSource: undefined,
259
+ bgBlur,
260
+ bgBlurRadius,
261
+ isSelfieMode,
262
+ };
263
+ }
264
+
265
+ public stop(): void {
266
+ this.abortController.abort();
267
+ this.webGlRenderer.close();
268
+ this.generator.stop();
269
+
270
+ if (this.segmenter) {
271
+ this.segmenter.close();
272
+ this.segmenter = null;
273
+ }
274
+ this.isSegmenterReady = false;
275
+ }
276
+ }