Skip to content

Commit b06e130

Browse files
authored
fix(react): Prevent frame exhaustion in fallback MediaStreamTrackProcessor (#2019)
### 💡 Overview Stop `pull()` from exhausting frames in fallback MediaStreamTrackProcessor by performing timestamp change check in `transform()` ### 📝 Implementation notes 🎫 Ticket: https://linear.app/stream/issue/XYZ-123 📑 Docs: https://github.com/GetStream/docs-content/pull/<id>
1 parent 075cf8b commit b06e130

File tree

2 files changed

+77
-94
lines changed

2 files changed

+77
-94
lines changed

packages/video-filters-web/src/FallbackProcessor.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ class FallbackProcessor implements MediaStreamTrackProcessor<VideoFrame> {
4949
let timestamp = 0;
5050
const frameRate = track.getSettings().frameRate || 30;
5151
let frameDuration = 1000 / frameRate;
52-
let lastVideoTime = -1;
5352

5453
this.workerTimer = new WorkerTimer({ useWorker: true });
5554
this.readable = new ReadableStream({
@@ -77,18 +76,6 @@ class FallbackProcessor implements MediaStreamTrackProcessor<VideoFrame> {
7776
}
7877
timestamp = performance.now();
7978

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-
9279
if (
9380
canvas.width !== this.video.videoWidth ||
9481
canvas.height !== this.video.videoHeight
@@ -100,7 +87,9 @@ class FallbackProcessor implements MediaStreamTrackProcessor<VideoFrame> {
10087
ctx.drawImage(this.video, 0, 0);
10188

10289
try {
103-
const frame = new VideoFrame(canvas, { timestamp });
90+
const frame = new VideoFrame(canvas, {
91+
timestamp: Math.round(this.video.currentTime * 1000000),
92+
});
10493
controller.enqueue(frame);
10594
} catch (err) {
10695
running = false;

packages/video-filters-web/src/VirtualBackground.ts

Lines changed: 74 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ export class VirtualBackground {
2121

2222
private canvas!: OffscreenCanvas;
2323
private segmenter: ImageSegmenter | null = null;
24-
private isSegmenterReady = false;
2524
private webGlRenderer!: WebGLRenderer;
2625
private abortController: AbortController;
2726

2827
private segmenterDelayTotal = 0;
2928
private frames = 0;
3029
private lastStatsTime = 0;
3130

31+
private latestCategoryMask: WebGLTexture | undefined = undefined;
32+
private latestConfidenceMask: WebGLTexture | undefined = undefined;
33+
3234
constructor(
3335
private readonly track: MediaStreamVideoTrack,
3436
private readonly options: BackgroundOptions = {},
@@ -59,22 +61,43 @@ export class VirtualBackground {
5961

6062
const opts = await this.initializeSegmenterOptions();
6163

64+
let lastFrameTime = -1;
6265
const transformStream = new TransformStream<VideoFrame, VideoFrame>({
63-
transform: async (frame, controller) => {
66+
transform: (frame, controller) => {
6467
try {
6568
if (this.abortController.signal.aborted) {
66-
return frame.close();
69+
frame.close();
70+
return;
6771
}
6872

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);
73+
if (
74+
this.canvas.width !== frame.displayWidth ||
75+
this.canvas.height !== frame.displayHeight
76+
) {
77+
this.canvas.width = frame.displayWidth;
78+
this.canvas.height = frame.displayHeight;
79+
}
7480

75-
if (!this.abortController.signal.aborted) {
76-
controller.enqueue(frame);
81+
const currentTime = frame.timestamp;
82+
const hasNewFrame = currentTime !== lastFrameTime;
83+
84+
lastFrameTime = currentTime;
85+
if (hasNewFrame) {
86+
this.runSegmentation(frame);
7787
}
88+
this.webGlRenderer.render(
89+
frame,
90+
opts,
91+
this.latestCategoryMask,
92+
this.latestConfidenceMask,
93+
);
94+
95+
controller.enqueue(
96+
new VideoFrame(this.canvas, { timestamp: frame.timestamp }),
97+
);
98+
} catch (e) {
99+
console.error('[virtual-background] error processing frame:', e);
100+
onError?.(e);
78101
} finally {
79102
frame.close();
80103
}
@@ -84,7 +107,6 @@ export class VirtualBackground {
84107
this.segmenter.close();
85108
this.segmenter = null;
86109
}
87-
this.isSegmenterReady = false;
88110
},
89111
});
90112

@@ -103,6 +125,47 @@ export class VirtualBackground {
103125
return this.generator;
104126
}
105127

128+
private runSegmentation(frame: VideoFrame) {
129+
if (!this.segmenter) return;
130+
131+
const start = performance.now();
132+
133+
this.segmenter.segmentForVideo(frame, frame.timestamp, (result) => {
134+
try {
135+
this.latestCategoryMask = result.categoryMask?.getAsWebGLTexture();
136+
137+
this.latestConfidenceMask =
138+
result.confidenceMasks?.[0]?.getAsWebGLTexture();
139+
140+
const now = performance.now();
141+
this.segmenterDelayTotal += now - start;
142+
this.frames++;
143+
144+
if (this.lastStatsTime === 0) {
145+
this.lastStatsTime = now;
146+
}
147+
148+
if (now - this.lastStatsTime > 1000) {
149+
const delay =
150+
Math.round((this.segmenterDelayTotal / this.frames) * 100) / 100;
151+
const fps = Math.round(
152+
(1000 * this.frames) / (now - this.lastStatsTime),
153+
);
154+
155+
this.hooks.onStats?.({ delay, fps, timestamp: now });
156+
157+
this.lastStatsTime = now;
158+
this.segmenterDelayTotal = 0;
159+
this.frames = 0;
160+
}
161+
} catch (err) {
162+
console.error('[virtual-background] segmentation error:', err);
163+
} finally {
164+
result.close();
165+
}
166+
});
167+
}
168+
106169
/**
107170
* Loads and initializes the MediaPipe `ImageSegmenter`.
108171
*/
@@ -130,82 +193,14 @@ export class VirtualBackground {
130193
outputConfidenceMasks: true,
131194
canvas: this.canvas,
132195
});
133-
134-
this.isSegmenterReady = true;
135196
} catch (error) {
136197
console.error(
137198
'[virtual-background] Failed to initialize MediaPipe segmenter:',
138199
error,
139200
);
140-
this.isSegmenterReady = false;
141201
}
142202
}
143203

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-
209204
private async loadBackground(url: string | undefined) {
210205
if (!url) {
211206
return;
@@ -271,6 +266,5 @@ export class VirtualBackground {
271266
this.segmenter.close();
272267
this.segmenter = null;
273268
}
274-
this.isSegmenterReady = false;
275269
}
276270
}

0 commit comments

Comments
 (0)