@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.
- package/CHANGELOG.md +16 -0
- package/dist/index.cjs.js +449 -127
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +449 -128
- package/dist/index.es.js.map +1 -1
- package/dist/src/BaseVideoProcessor.d.ts +38 -0
- package/dist/src/FullScreenBlur.d.ts +28 -0
- package/dist/src/FullScreenBlurRenderer.d.ts +42 -0
- package/dist/src/VirtualBackground.d.ts +15 -28
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/BaseVideoProcessor.ts +132 -0
- package/src/FallbackProcessor.ts +4 -14
- package/src/FullScreenBlur.ts +52 -0
- package/src/FullScreenBlurRenderer.ts +411 -0
- package/src/VirtualBackground.ts +83 -184
package/src/VirtualBackground.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
29
|
-
|
|
30
|
-
private
|
|
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
|
-
|
|
30
|
+
track: MediaStreamVideoTrack,
|
|
34
31
|
private readonly options: BackgroundOptions = {},
|
|
35
|
-
|
|
32
|
+
hooks: VideoTrackProcessorHooks = {},
|
|
36
33
|
) {
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
48
|
+
this.options.basePath ||
|
|
113
49
|
`https://unpkg.com/${packageName}@${version}/mediapipe`;
|
|
114
50
|
|
|
115
|
-
const
|
|
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(
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
76
|
+
if (hasNewFrame && this.isSegmenterReady && this.segmenter) {
|
|
77
|
+
await this.runSegmentation(frame);
|
|
182
78
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
210
|
-
if (!
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
}
|