@stream-io/video-filters-web 0.5.0 → 0.6.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.
@@ -7,124 +7,54 @@ import {
7
7
  import { FilesetResolver, ImageSegmenter } from '@mediapipe/tasks-vision';
8
8
  import { WebGLRenderer } from './WebGLRenderer';
9
9
  import { packageName, version } from './version';
10
- import { TrackGenerator, MediaStreamTrackGenerator } from './FallbackGenerator';
11
- import { MediaStreamTrackProcessor, TrackProcessor } from './FallbackProcessor';
10
+ import { BaseVideoProcessor } from './BaseVideoProcessor';
12
11
 
13
12
  /**
14
13
  * Wraps a video MediaStreamTrack in a real-time processing pipeline.
15
14
  * Incoming frames are processed through a transformer and re-emitted
16
15
  * on a new MediaStreamVideoTrack for downstream consumption.
17
16
  */
18
- export class VirtualBackground {
19
- private readonly processor: MediaStreamTrackProcessor<VideoFrame>;
20
- private readonly generator: MediaStreamTrackGenerator<VideoFrame>;
21
-
22
- private canvas!: OffscreenCanvas;
17
+ export class VirtualBackground extends BaseVideoProcessor {
23
18
  private segmenter: ImageSegmenter | null = null;
24
19
  private isSegmenterReady = false;
25
20
  private webGlRenderer!: WebGLRenderer;
26
- private abortController: AbortController;
27
21
 
28
- private segmenterDelayTotal = 0;
29
- private frames = 0;
30
- private lastStatsTime = 0;
22
+ private opts!: SegmenterOptions;
23
+
24
+ private latestCategoryMask: WebGLTexture | undefined = undefined;
25
+ private latestConfidenceMask: WebGLTexture | undefined = undefined;
26
+ private lastFrameTime = -1;
27
+ private count = 0;
31
28
 
32
29
  constructor(
33
- private readonly track: MediaStreamVideoTrack,
30
+ track: MediaStreamVideoTrack,
34
31
  private readonly options: BackgroundOptions = {},
35
- private readonly hooks: VideoTrackProcessorHooks = {},
32
+ hooks: VideoTrackProcessorHooks = {},
36
33
  ) {
37
- this.processor = new TrackProcessor({ track });
38
- this.generator = new TrackGenerator({
39
- kind: 'video',
40
- signalTarget: track,
41
- });
42
-
43
- this.abortController = new AbortController();
34
+ super(track, hooks);
44
35
  }
45
36
 
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);
37
+ protected async initialize(): Promise<void> {
56
38
  this.webGlRenderer = new WebGLRenderer(this.canvas);
57
39
 
58
40
  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
41
  }
105
42
 
106
- /**
107
- * Loads and initializes the MediaPipe `ImageSegmenter`.
108
- */
109
43
  private async initializeSegmenter() {
110
44
  try {
45
+ this.opts = await this.initializeSegmenterOptions();
46
+
111
47
  const basePath =
112
- this.options?.basePath ||
48
+ this.options.basePath ||
113
49
  `https://unpkg.com/${packageName}@${version}/mediapipe`;
114
50
 
115
- const defaultModelPath = `${basePath}/models/selfie_segmenter.tflite`;
116
-
117
- const model = this.options?.modelPath || defaultModelPath;
118
-
119
- const wasmPath = `${basePath}/wasm`;
51
+ const model =
52
+ this.options.modelPath || `${basePath}/models/selfie_segmenter.tflite`;
120
53
 
121
- const fileset = await FilesetResolver.forVisionTasks(wasmPath);
54
+ const fileset = await FilesetResolver.forVisionTasks(`${basePath}/wasm`);
122
55
 
123
56
  this.segmenter = await ImageSegmenter.createFromOptions(fileset, {
124
- baseOptions: {
125
- modelAssetPath: model,
126
- delegate: 'GPU',
127
- },
57
+ baseOptions: { modelAssetPath: model, delegate: 'GPU' },
128
58
  runningMode: 'VIDEO',
129
59
  outputCategoryMask: true,
130
60
  outputConfidenceMasks: true,
@@ -133,107 +63,60 @@ export class VirtualBackground {
133
63
 
134
64
  this.isSegmenterReady = true;
135
65
  } catch (error) {
136
- console.error(
137
- '[virtual-background] Failed to initialize MediaPipe segmenter:',
138
- error,
139
- );
66
+ console.error('[virtual-background] Segmenter init failed:', error);
140
67
  this.isSegmenterReady = false;
141
68
  }
142
69
  }
143
70
 
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++;
71
+ protected async transform(frame: VideoFrame): Promise<VideoFrame> {
72
+ const currentTime = frame.timestamp;
73
+ const hasNewFrame = currentTime !== this.lastFrameTime;
74
+ this.lastFrameTime = currentTime;
178
75
 
179
- if (this.lastStatsTime === 0) {
180
- this.lastStatsTime = now;
181
- }
76
+ if (hasNewFrame && this.isSegmenterReady && this.segmenter) {
77
+ await this.runSegmentation(frame);
182
78
 
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
- }
79
+ this.webGlRenderer.render(
80
+ frame,
81
+ this.opts,
82
+ this.latestCategoryMask,
83
+ this.latestConfidenceMask,
84
+ );
204
85
  }
205
86
 
206
87
  return new VideoFrame(this.canvas, { timestamp: frame.timestamp });
207
88
  }
208
89
 
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();
90
+ private async runSegmentation(frame: VideoFrame): Promise<void> {
91
+ if (!this.segmenter) return;
222
92
 
223
- const imageBitmap = await createImageBitmap(blob);
224
- return { type: 'image', media: imageBitmap, url };
93
+ return new Promise<void>((resolve) => {
94
+ const timestamp = Math.floor(performance.now());
95
+ this.segmenter!.segmentForVideo(frame, timestamp, (result) => {
96
+ try {
97
+ this.latestCategoryMask = result.categoryMask?.getAsWebGLTexture();
98
+ this.latestConfidenceMask =
99
+ result.confidenceMasks?.[0]?.getAsWebGLTexture();
100
+ } catch (err) {
101
+ console.error('[virtual-background] segmentation error:', err);
102
+ this.hooks.onError?.(err);
103
+ } finally {
104
+ result.close();
105
+ resolve();
106
+ }
107
+ });
108
+ });
225
109
  }
226
110
 
227
111
  private async initializeSegmenterOptions(): Promise<SegmenterOptions> {
228
112
  const isSelfieMode = this.options.modelPath
229
- ? this.options.modelPath?.includes('selfie_segmenter')
113
+ ? this.options.modelPath.includes('selfie_segmenter')
230
114
  : true;
231
115
 
232
116
  if (this.options.backgroundFilter === 'image') {
117
+ const source = await this.loadBackground(this.options.backgroundImage);
233
118
  return {
234
- backgroundSource: await this.loadBackground(
235
- this.options.backgroundImage,
236
- ),
119
+ backgroundSource: source,
237
120
  bgBlur: 0,
238
121
  bgBlurRadius: 0,
239
122
  isSelfieMode,
@@ -241,6 +124,7 @@ export class VirtualBackground {
241
124
  }
242
125
 
243
126
  const blurLevel = this.options.backgroundBlurLevel;
127
+
244
128
  if (typeof blurLevel === 'string') {
245
129
  return {
246
130
  ...BACKGROUND_BLUR_MAP[blurLevel],
@@ -250,27 +134,42 @@ export class VirtualBackground {
250
134
  }
251
135
 
252
136
  const numeric = blurLevel ?? 5;
253
-
254
- const bgBlur = Math.min(numeric * 3, 30);
255
- const bgBlurRadius = Math.min(numeric, 10);
256
-
257
137
  return {
258
138
  backgroundSource: undefined,
259
- bgBlur,
260
- bgBlurRadius,
139
+ bgBlur: Math.min(numeric * 3, 30),
140
+ bgBlurRadius: Math.min(numeric, 10),
261
141
  isSelfieMode,
262
142
  };
263
143
  }
264
144
 
265
- public stop(): void {
266
- this.abortController.abort();
267
- this.webGlRenderer.close();
268
- this.generator.stop();
145
+ private async loadBackground(url?: string) {
146
+ if (!url) return null;
147
+ const result = await fetch(url);
148
+ if (!result.ok) return null;
269
149
 
270
- if (this.segmenter) {
271
- this.segmenter.close();
272
- this.segmenter = null;
273
- }
150
+ return {
151
+ type: 'image',
152
+ media: await createImageBitmap(await result.blob()),
153
+ url,
154
+ };
155
+ }
156
+
157
+ protected onFlush(): void {
158
+ this.destroySegmenter();
159
+ }
160
+
161
+ protected onStop(): void {
162
+ this.webGlRenderer?.close();
163
+ this.destroySegmenter();
164
+ }
165
+
166
+ private destroySegmenter() {
167
+ this.segmenter?.close();
168
+ this.segmenter = null;
274
169
  this.isSegmenterReady = false;
275
170
  }
171
+
172
+ protected get processorName() {
173
+ return 'background-processor';
174
+ }
276
175
  }