Skip to content

Commit e242d35

Browse files
authored
feat(react): Video Call moderation for React SDK (#2007)
### 💡 Overview Adds automatic full-screen blur when a moderation event is received, with optional auto-removal after a set duration. Also adds a lightweight warning notification when moderation warnings are triggered. ### 📝 Implementation notes - Introduced `useModeration` hook which applies full-screen blur when moderation event is submitted - If full-screen blur is unsupported on the device fallback is turning off the camera - Implement ModerationNotification when warning event is emitted 🎫 Ticket: https://linear.app/stream/issue/REACT-625/video-call-moderation-for-react 📑 Docs: GetStream/docs-content#773
1 parent 307f954 commit e242d35

File tree

12 files changed

+898
-184
lines changed

12 files changed

+898
-184
lines changed

packages/react-sdk/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
useRequestPermission,
1515
usePersistedDevicePreferences,
1616
useDeviceList,
17+
useModeration,
1718
} from './src/hooks';
1819
export { applyFilter, type Filter } from './src/utilities/filter';
1920

packages/react-sdk/src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './usePersistedDevicePreferences';
33
export * from './useScrollPosition';
44
export * from './useRequestPermission';
55
export * from './useDeviceList';
6+
export * from './useModeration';
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
import { useCall } from '@stream-io/video-react-bindings';
3+
4+
type FullScreenBlurType =
5+
typeof import('@stream-io/video-filters-web').FullScreenBlur;
6+
7+
const isFullScreenBlurPlatformSupported = (): boolean => {
8+
if (
9+
typeof window === 'undefined' ||
10+
typeof OffscreenCanvas === 'undefined' ||
11+
typeof VideoFrame === 'undefined' ||
12+
!window.WebGL2RenderingContext
13+
) {
14+
return false;
15+
}
16+
17+
try {
18+
const canvas = new OffscreenCanvas(1, 1);
19+
return !!canvas.getContext('webgl2', {
20+
alpha: false,
21+
antialias: false,
22+
desynchronized: true,
23+
});
24+
} catch {
25+
return false;
26+
}
27+
};
28+
29+
export interface ModerationOptions {
30+
/**
31+
* How long the moderation effect should stay active before being disabled.
32+
* Set to `0` to keep it active indefinitely. Defaults to 5000 ms.
33+
*/
34+
duration?: number;
35+
}
36+
37+
export const useModeration = (options?: ModerationOptions) => {
38+
const { duration = 5000 } = options || {};
39+
40+
const call = useCall();
41+
42+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43+
const processorRef = useRef<InstanceType<FullScreenBlurType> | null>(null);
44+
const unregisterRef = useRef<(() => Promise<void>) | null>(null);
45+
46+
const blurModulePromise = useRef<Promise<FullScreenBlurType> | null>(null);
47+
48+
/**
49+
* Lazily loads and caches the video-filters-web module.
50+
*/
51+
const loadVideoFiltersWebModule = useCallback(() => {
52+
if (!blurModulePromise.current) {
53+
blurModulePromise.current = import('@stream-io/video-filters-web')
54+
.then((module) => module.FullScreenBlur)
55+
.catch((error) => {
56+
console.error('[moderation] Failed to import blur module:', error);
57+
throw error;
58+
});
59+
}
60+
61+
return blurModulePromise.current;
62+
}, []);
63+
64+
const disableBlur = useCallback(() => {
65+
if (timeoutRef.current) {
66+
clearTimeout(timeoutRef.current);
67+
timeoutRef.current = null;
68+
}
69+
70+
unregisterRef
71+
.current?.()
72+
.catch((err) => console.error('[moderation] unregister error:', err));
73+
74+
unregisterRef.current = null;
75+
}, []);
76+
77+
const handleFallback = useCallback(async () => {
78+
try {
79+
await call?.camera.disable();
80+
} catch (error) {
81+
console.error('[moderation] Failed to disable camera:', error);
82+
}
83+
}, [call]);
84+
85+
useEffect(() => {
86+
if (!call) return;
87+
88+
return call.on('call.moderation_warning', async () => {
89+
try {
90+
await loadVideoFiltersWebModule();
91+
} catch (importErr) {
92+
console.error('[moderation] Failed to import blur module:', importErr);
93+
}
94+
});
95+
}, [call, loadVideoFiltersWebModule]);
96+
97+
useEffect(() => {
98+
if (!call) return;
99+
100+
return call.on('call.moderation_blur', async () => {
101+
if (unregisterRef.current) return;
102+
103+
let FullScreenBlurClass: FullScreenBlurType;
104+
105+
try {
106+
FullScreenBlurClass = await loadVideoFiltersWebModule();
107+
} catch (importErr) {
108+
console.error('[moderation] Failed to import blur module:', importErr);
109+
await handleFallback();
110+
return;
111+
}
112+
113+
if (!isFullScreenBlurPlatformSupported()) {
114+
console.warn('[moderation] Blur not supported on this platform');
115+
await handleFallback();
116+
return;
117+
}
118+
119+
const { unregister } = call.camera.registerFilter((inputStream) => {
120+
unregisterRef.current = unregister;
121+
122+
const outputPromise = new Promise<MediaStream>(
123+
async (resolve, reject) => {
124+
const [track] = inputStream.getVideoTracks();
125+
126+
let processor: InstanceType<FullScreenBlurType>;
127+
128+
try {
129+
processor = new FullScreenBlurClass(track);
130+
processorRef.current = processor;
131+
132+
const result = await processor.start();
133+
const output = new MediaStream([result]);
134+
resolve(output);
135+
136+
if (duration > 0) {
137+
timeoutRef.current = setTimeout(disableBlur, duration);
138+
}
139+
} catch (error) {
140+
reject(error);
141+
console.error('[moderation] Processor init failed:', error);
142+
143+
await unregisterRef.current?.();
144+
unregisterRef.current = null;
145+
processorRef.current = null;
146+
147+
await handleFallback();
148+
return;
149+
}
150+
},
151+
);
152+
153+
return {
154+
output: outputPromise,
155+
stop: () => {
156+
if (processorRef.current) {
157+
processorRef.current.stop();
158+
processorRef.current = null;
159+
}
160+
},
161+
};
162+
});
163+
});
164+
}, [call, loadVideoFiltersWebModule, disableBlur, handleFallback, duration]);
165+
166+
useEffect(() => disableBlur, [disableBlur]);
167+
};

packages/video-filters-web/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './src/legacy/tflite';
55
export * from './src/mediapipe';
66
export * from './src/types';
77
export * from './src/VirtualBackground';
8+
export * from './src/FullScreenBlur';
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { VideoTrackProcessorHooks } from './types';
2+
import { TrackGenerator, MediaStreamTrackGenerator } from './FallbackGenerator';
3+
import { MediaStreamTrackProcessor, TrackProcessor } from './FallbackProcessor';
4+
5+
/**
6+
* Base class for real-time video filters.
7+
*
8+
* It sets up the full pipeline that reads frames from the input track,
9+
* processes them, and outputs a new track with your effect applied. Subclasses
10+
* only need to implement `initialize` (run once before processing starts) and
11+
* `transform` (called for every frame).
12+
*
13+
* Everything else—canvas setup, performance tracking, error handling, and
14+
* clean shutdown is handled for you. Calling `start()` returns a processed
15+
* `MediaStreamTrack` ready to use.
16+
*/
17+
export abstract class BaseVideoProcessor {
18+
protected readonly processor: MediaStreamTrackProcessor<VideoFrame>;
19+
protected readonly generator: MediaStreamTrackGenerator<VideoFrame>;
20+
21+
protected readonly hooks: VideoTrackProcessorHooks;
22+
23+
protected readonly abortController = new AbortController();
24+
protected canvas!: OffscreenCanvas;
25+
26+
private frames = 0;
27+
private delayTotal = 0;
28+
private lastStatsTime = 0;
29+
30+
/**
31+
* Constructs a new instance.
32+
*/
33+
protected constructor(
34+
protected readonly track: MediaStreamVideoTrack,
35+
hooks: VideoTrackProcessorHooks = {},
36+
) {
37+
this.processor = new TrackProcessor({ track });
38+
this.generator = new TrackGenerator({
39+
kind: 'video',
40+
signalTarget: track,
41+
});
42+
this.hooks = hooks;
43+
}
44+
45+
public async start(): Promise<MediaStreamTrack> {
46+
const { readable } = this.processor;
47+
const { writable } = this.generator;
48+
49+
const { width = 1280, height = 720 } = this.track.getSettings();
50+
this.canvas = new OffscreenCanvas(width, height);
51+
52+
await this.initialize();
53+
54+
const transformStream = new TransformStream<VideoFrame, VideoFrame>({
55+
transform: async (frame, controller) => {
56+
try {
57+
if (this.abortController.signal.aborted) return frame.close();
58+
59+
if (
60+
this.canvas.width !== frame.displayWidth ||
61+
this.canvas.height !== frame.displayHeight
62+
) {
63+
this.canvas.width = frame.displayWidth;
64+
this.canvas.height = frame.displayHeight;
65+
}
66+
67+
const start = performance.now();
68+
const processed = await this.transform(frame);
69+
const delay = performance.now() - start;
70+
71+
this.updateStats(delay);
72+
controller.enqueue(processed);
73+
} catch (e) {
74+
this.hooks.onError?.(e);
75+
} finally {
76+
frame.close();
77+
}
78+
},
79+
flush: () => this.onFlush(),
80+
});
81+
82+
readable
83+
.pipeThrough(transformStream, { signal: this.abortController.signal })
84+
.pipeTo(writable, { signal: this.abortController.signal })
85+
.catch((e) => {
86+
if (e.name !== 'AbortError' && e.name !== 'InvalidStateError') {
87+
console.error(`[${this.processorName}] Error processing track:`, e);
88+
this.hooks.onError?.(e);
89+
}
90+
});
91+
92+
return this.generator;
93+
}
94+
95+
public stop(): void {
96+
this.abortController.abort();
97+
this.generator.stop();
98+
this.onStop();
99+
}
100+
101+
private updateStats(delay: number): void {
102+
this.frames++;
103+
this.delayTotal += delay;
104+
105+
const now = performance.now();
106+
if (this.lastStatsTime === 0) {
107+
this.lastStatsTime = now;
108+
return;
109+
}
110+
111+
if (now - this.lastStatsTime >= 1000) {
112+
const avgDelay = Math.round((this.delayTotal / this.frames) * 100) / 100;
113+
const fps = Math.round((1000 * this.frames) / (now - this.lastStatsTime));
114+
115+
this.hooks.onStats?.({ delay: avgDelay, fps, timestamp: now });
116+
117+
this.frames = 0;
118+
this.delayTotal = 0;
119+
this.lastStatsTime = now;
120+
}
121+
}
122+
123+
protected abstract initialize(): Promise<void>;
124+
protected abstract transform(frame: VideoFrame): Promise<VideoFrame>;
125+
126+
protected onFlush(): void {}
127+
protected onStop(): void {}
128+
129+
protected get processorName(): string {
130+
return 'base-processor';
131+
}
132+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class FallbackProcessor implements MediaStreamTrackProcessor<VideoFrame> {
9090
const frame = new VideoFrame(canvas, {
9191
timestamp: Math.round(this.video.currentTime * 1000000),
9292
});
93+
9394
controller.enqueue(frame);
9495
} catch (err) {
9596
running = false;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { VideoTrackProcessorHooks } from './types';
2+
import { BaseVideoProcessor } from './BaseVideoProcessor';
3+
import { FullScreenBlurRenderer } from './FullScreenBlurRenderer';
4+
5+
export interface FullScreenBlurOptions {
6+
blurRadius?: number;
7+
}
8+
9+
/**
10+
* A video filter that applies a full-screen blur to each frame.
11+
*
12+
* It uses a WebGL renderer to blur the incoming camera track and outputs
13+
* a new track with the effect applied. Setup and frame handling are managed
14+
* by the base processor.
15+
*/
16+
export class FullScreenBlur extends BaseVideoProcessor {
17+
private blurRenderer!: FullScreenBlurRenderer;
18+
private readonly blurRadius: number;
19+
20+
/**
21+
* Creates a new full-screen blur processor for the given video track.
22+
*
23+
* @param track - The input camera track to blur.
24+
* @param options - Optional settings such as the blur radius.
25+
* @param hooks - Optional callbacks for stats and error reporting.
26+
*/
27+
constructor(
28+
track: MediaStreamVideoTrack,
29+
options: FullScreenBlurOptions = {},
30+
hooks: VideoTrackProcessorHooks = {},
31+
) {
32+
super(track, hooks);
33+
this.blurRadius = options.blurRadius ?? 6;
34+
}
35+
36+
protected async initialize(): Promise<void> {
37+
this.blurRenderer = new FullScreenBlurRenderer(this.canvas);
38+
}
39+
40+
protected async transform(frame: VideoFrame): Promise<VideoFrame> {
41+
this.blurRenderer.render(frame, this.blurRadius);
42+
return new VideoFrame(this.canvas, { timestamp: frame.timestamp });
43+
}
44+
45+
protected onStop(): void {
46+
this.blurRenderer?.close();
47+
}
48+
49+
protected get processorName(): string {
50+
return 'fullscreen-blur';
51+
}
52+
}

0 commit comments

Comments
 (0)