Skip to content

Commit 05b274e

Browse files
authored
feat: add Fishjam background blur integration (#1080)
## Description Adds react-native-executorch-webrtc package for real-time background blur in Fishjam WebRTC video calls using on-device ExecuTorch segmentation models. Key features: - useBackgroundBlur hook providing blurMiddleware for Fishjam's useCamera - blur compositing (OpenGL ES on Android, Core Image on iOS) - Morphological mask cleaning + EMA temporal smoothing (C++/OpenCV) Architecture: - Reuses BaseSemanticSegmentation from react-native-executorch for inference - Registers custom VideoFrameProcessor with Fishjam's WebRTC pipeline - All heavy processing in native (C++/Objective-C++) for performance ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [x] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [x] Android ### Testing instructions You'll need to setup your fishjam account, and verify this example works properly: ```import { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; import { FishjamProvider, useConnection, useCamera, useInitializeDevices, useSandbox, RTCView, } from '@fishjam-cloud/react-native-client'; import { useBackgroundBlur } from 'react-native-executorch-webrtc'; import { ResourceFetcher, SELFIE_SEGMENTATION, initExecutorch } from 'react-native-executorch'; import { ExpoResourceFetcher } from 'react-native-executorch-expo-resource-fetcher'; initExecutorch({ resourceFetcher: ExpoResourceFetcher }); const FISHJAM_ID = 'your-id'; function CameraScreen() { const { initializeDevices } = useInitializeDevices(); const { cameraStream, cameraDevices, currentCamera, selectCamera, setCameraTrackMiddleware } = useCamera(); const { joinRoom, leaveRoom, peerStatus } = useConnection(); const { getSandboxPeerToken } = useSandbox(); const [isJoining, setIsJoining] = useState(false); const [modelPath, setModelPath] = useState<string | null>(null); const [downloadProgress, setDownloadProgress] = useState(0); const [blurEnabled, setBlurEnabled] = useState(false); const { blurMiddleware } = useBackgroundBlur({ modelUri: modelPath || '', blurRadius: 12, }); // Download the selfie segmentation model useEffect(() => { const downloadModel = async () => { try { const paths = await ResourceFetcher.fetch( (progress) => setDownloadProgress(progress), SELFIE_SEGMENTATION.modelSource ); if (paths?.[0]) { setModelPath(paths[0]); } } catch (error) { console.error('Failed to download model:', error); } }; downloadModel(); }, []); const handleFlipCamera = async () => { if (cameraDevices.length < 2) return; const currentIndex = cameraDevices.findIndex( (device) => device.deviceId === currentCamera?.deviceId ); const nextIndex = (currentIndex + 1) % cameraDevices.length; await selectCamera(cameraDevices[nextIndex].deviceId); }; const handleToggleBlur = async () => { if (!modelPath) return; if (blurEnabled) { await setCameraTrackMiddleware(null); setBlurEnabled(false); } else { await setCameraTrackMiddleware(blurMiddleware); setBlurEnabled(true); } }; useEffect(() => { initializeDevices(); }, []); const handleJoinRoom = async () => { setIsJoining(true); try { const roomName = 'demo-room'; const peerName = `user_${Date.now()}`; const peerToken = await getSandboxPeerToken(roomName, peerName); await joinRoom({ peerToken }); } catch (error) { console.error('Failed to join room:', error); } finally { setIsJoining(false); } }; return ( <View style={styles.container}> <StatusBar style="light" /> <View style={styles.videoContainer}> {cameraStream ? ( <RTCView mediaStream={cameraStream} style={styles.video} objectFit="cover" mirror={true} /> ) : ( <View style={styles.placeholder}> <Text style={styles.placeholderText}>Starting camera...</Text> </View> )} </View> <View style={styles.controls}> <Text style={styles.status}>Status: {peerStatus}</Text> {downloadProgress > 0 && downloadProgress < 1 && ( <Text style={styles.status}> Downloading model: {(downloadProgress * 100).toFixed(0)}% </Text> )} <View style={styles.buttons}> <TouchableOpacity style={styles.flipButton} onPress={handleFlipCamera}> <Text style={styles.buttonText}>Flip</Text> </TouchableOpacity> <TouchableOpacity style={[styles.blurButton, blurEnabled && styles.blurButtonActive, !modelPath && styles.buttonDisabled]} onPress={handleToggleBlur} disabled={!modelPath} > <Text style={styles.buttonText}>{blurEnabled ? 'Blur On' : 'Blur'}</Text> </TouchableOpacity> {peerStatus === 'connected' ? ( <TouchableOpacity style={styles.leaveButton} onPress={leaveRoom}> <Text style={styles.buttonText}>Leave Room</Text> </TouchableOpacity> ) : ( <TouchableOpacity style={[styles.button, isJoining && styles.buttonDisabled]} onPress={handleJoinRoom} disabled={isJoining} > <Text style={styles.buttonText}> {isJoining ? 'Joining...' : 'Join Room'} </Text> </TouchableOpacity> )} </View> </View> </View> ); } export default function App() { return ( <FishjamProvider fishjamId={FISHJAM_ID}> <CameraScreen /> </FishjamProvider> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#000', }, videoContainer: { flex: 1, }, video: { flex: 1, }, placeholder: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1a1a1a', }, placeholderText: { color: '#666', fontSize: 18, }, controls: { padding: 20, paddingBottom: 40, alignItems: 'center', gap: 12, }, status: { color: '#888', fontSize: 14, }, buttons: { flexDirection: 'row', gap: 12, }, button: { backgroundColor: '#007AFF', paddingHorizontal: 32, paddingVertical: 14, borderRadius: 12, }, flipButton: { backgroundColor: '#333', paddingHorizontal: 24, paddingVertical: 14, borderRadius: 12, }, leaveButton: { backgroundColor: '#FF3B30', paddingHorizontal: 32, paddingVertical: 14, borderRadius: 12, }, blurButton: { backgroundColor: '#5856D6', paddingHorizontal: 24, paddingVertical: 14, borderRadius: 12, }, blurButtonActive: { backgroundColor: '#34C759', }, buttonDisabled: { backgroundColor: '#444', }, buttonText: { color: '#fff', fontSize: 16, fontWeight: '600', }, }); ``` ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues <!-- Link related issues here using #issue-number --> ### Checklist - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings ### Additional notes <!-- Include any additional information, assumptions, or context that reviewers might need to understand this PR. -->
1 parent 7992a10 commit 05b274e

33 files changed

Lines changed: 2901 additions & 0 deletions

.cspell-wordlist.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,8 @@ keypoints
198198
Keypoint
199199
Keypoints
200200
letterboxing
201+
webrtc
202+
fishjam
203+
Fishjam
204+
deinitialize
205+
Deinitialize
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
title: Fishjam Usage
3+
---
4+
5+
import Tabs from '@theme/Tabs';
6+
import TabItem from '@theme/TabItem';
7+
8+
:::danger
9+
This integration is currently in beta.
10+
:::
11+
12+
## Overview
13+
14+
Starting from v0.9.0, you can use `react-native-executorch` powered background blur integration in your [Fishjam](https://fishjam.io) applications. The package `react-native-executorch-webrtc` exposes a hook, which returns a middleware for your camera streams. We plan to extend this to other models, such as text to speech, speech to text, or other vision models in the future.
15+
16+
## Installation
17+
18+
Install the package with your package manager of choice. Make sure to also have `react-native-executorch` and a resource fetcher adapter installed (see [Getting Started](../01-fundamentals/01-getting-started.md)).
19+
20+
<Tabs>
21+
<TabItem value="npm" label="NPM">
22+
23+
```bash
24+
npm install react-native-executorch-webrtc
25+
```
26+
27+
</TabItem>
28+
<TabItem value="pnpm" label="PNPM">
29+
30+
```bash
31+
pnpm install react-native-executorch-webrtc
32+
```
33+
34+
</TabItem>
35+
<TabItem value="yarn" label="YARN">
36+
37+
```bash
38+
yarn add react-native-executorch-webrtc
39+
```
40+
41+
</TabItem>
42+
</Tabs>
43+
44+
The following peer dependencies must also be installed in your app:
45+
46+
- `@fishjam-cloud/react-native-client`
47+
- `@fishjam-cloud/react-native-webrtc`
48+
- `react-native-executorch`
49+
50+
## Usage
51+
52+
The integration is built around the `SELFIE_SEGMENTATION` model that we export from `react-native-executorch`. It's the only model we currently support and tune for — the blur pipeline expects its specific input shape and output classes, so other segmentation models will not work correctly.
53+
54+
Use `ResourceFetcher` together with `SELFIE_SEGMENTATION.modelSource` to download (and cache) the model, then pass the resulting path to `useBackgroundBlur`. The returned `blurMiddleware` plugs into Fishjam's `cameraTrackMiddleware`.
55+
56+
```tsx
57+
import { useEffect, useState } from 'react';
58+
import { Button, Text } from 'react-native';
59+
import { ResourceFetcher, SELFIE_SEGMENTATION } from 'react-native-executorch';
60+
import { useBackgroundBlur } from 'react-native-executorch-webrtc';
61+
import { useCamera } from '@fishjam-cloud/react-native-client';
62+
63+
function VideoCall() {
64+
const [modelUri, setModelUri] = useState<string | null>(null);
65+
66+
useEffect(() => {
67+
ResourceFetcher.fetch(() => {}, SELFIE_SEGMENTATION.modelSource).then(
68+
(paths) => paths?.[0] && setModelUri(paths[0])
69+
);
70+
}, []);
71+
72+
// Wait for the model to be available before mounting the hook —
73+
// useBackgroundBlur expects a real path, not an empty string.
74+
if (!modelUri) {
75+
return <Text>Downloading model…</Text>;
76+
}
77+
78+
return <VideoCallWithBlur modelUri={modelUri} />;
79+
}
80+
81+
function VideoCallWithBlur({ modelUri }: { modelUri: string }) {
82+
const [blurEnabled, setBlurEnabled] = useState(true);
83+
84+
const { blurMiddleware } = useBackgroundBlur({
85+
modelUri,
86+
blurRadius: 15,
87+
});
88+
89+
useCamera({
90+
cameraTrackMiddleware: blurEnabled ? blurMiddleware : undefined,
91+
});
92+
93+
return (
94+
<Button
95+
title={blurEnabled ? 'Disable Blur' : 'Enable Blur'}
96+
onPress={() => setBlurEnabled(!blurEnabled)}
97+
/>
98+
);
99+
}
100+
```
101+
102+
## API
103+
104+
### `useBackgroundBlur(options)`
105+
106+
| Option | Type | Description |
107+
| ------------ | -------- | -------------------------------------------------------------------------- |
108+
| `modelUri` | `string` | **Required.** Path or `file://` URI to the segmentation `.pte` model. |
109+
| `blurRadius` | `number` | Optional. Gaussian blur sigma applied to the background. Defaults to `12`. |
110+
111+
Returns:
112+
113+
| Field | Type | Description |
114+
| ---------------- | ----------------- | -------------------------------------------------------------- |
115+
| `blurMiddleware` | `TrackMiddleware` | Pass to `useCamera({ cameraTrackMiddleware })` to enable blur. |
116+
117+
The hook initializes the native processor on mount and releases it on unmount, so you don't need to manage its lifecycle manually.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# react-native-executorch-webrtc
2+
3+
Real-time background blur for Fishjam WebRTC applications, powered by ExecuTorch segmentation models.
4+
5+
This package provides GPU-accelerated background blur effects using on-device ExecuTorch models for foreground / background segmentation.
6+
7+
## Requirements
8+
9+
- iOS 13.0+
10+
- Android SDK 26+
11+
- Peer dependencies:
12+
- `@fishjam-cloud/react-native-client`
13+
- `@fishjam-cloud/react-native-webrtc`
14+
- `react-native-executorch`
15+
16+
## Installation
17+
18+
```bash
19+
yarn add react-native-executorch-webrtc
20+
```
21+
22+
For iOS:
23+
```bash
24+
cd ios && pod install
25+
```
26+
27+
## Usage
28+
29+
### With Fishjam SDK
30+
31+
```tsx
32+
import { useBackgroundBlur } from 'react-native-executorch-webrtc';
33+
import { useCamera } from '@fishjam-cloud/react-native-client';
34+
35+
function VideoCall() {
36+
const [blurEnabled, setBlurEnabled] = useState(true);
37+
38+
const { blurMiddleware } = useBackgroundBlur({
39+
// NOTE: you can use React Native Executorch's Resource Fetcher to download model files
40+
modelUri: 'file:///path/to/selfie_segmenter.pte',
41+
blurRadius: 15,
42+
});
43+
44+
const { toggleCamera } = useCamera({
45+
cameraTrackMiddleware: blurEnabled ? blurMiddleware : undefined,
46+
});
47+
48+
return (
49+
<Button
50+
title={blurEnabled ? 'Disable Blur' : 'Enable Blur'}
51+
onPress={() => setBlurEnabled(!blurEnabled)}
52+
/>
53+
);
54+
}
55+
```
56+
57+
## API
58+
59+
### `useBackgroundBlur(options)`
60+
61+
React hook that provides camera track middleware for background blur.
62+
63+
**Options:**
64+
- `modelUri`: `string` - Path to the ExecuTorch segmentation model (.pte file)
65+
- `blurRadius`: `number` (optional, default: 12) - Blur intensity
66+
67+
**Returns:**
68+
- `blurMiddleware`: `TrackMiddleware` - Middleware function for use with Fishjam's `useCamera`
69+
70+
### Blur Radius
71+
72+
The `blurRadius` can be updated dynamically without reinitializing:
73+
74+
```tsx
75+
const { blurMiddleware } = useBackgroundBlur({
76+
modelUri: modelPath,
77+
blurRadius: intensity,
78+
});
79+
```
80+
81+
## Platform Notes
82+
83+
### iOS
84+
Uses Core Image for GPU-accelerated blur compositing.
85+
86+
### Android
87+
Uses OpenGL ES shaders for blur and compositing. OpenMP is used for parallel CPU operations.
88+
89+
## License
90+
91+
MIT
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
cmake_minimum_required(VERSION 3.13)
2+
project(react-native-executorch-webrtc)
3+
4+
set(CMAKE_VERBOSE_MAKEFILE ON)
5+
set(CMAKE_CXX_STANDARD 20)
6+
7+
# Paths to react-native-executorch
8+
set(RN_EXECUTORCH_DIR "${CMAKE_SOURCE_DIR}/../../react-native-executorch")
9+
set(RN_EXECUTORCH_THIRD_PARTY "${RN_EXECUTORCH_DIR}/third-party/include")
10+
11+
# Add our JNI bridge source files
12+
file(GLOB SOURCES "src/main/cpp/*.cpp")
13+
14+
add_library(
15+
${CMAKE_PROJECT_NAME}
16+
SHARED
17+
${SOURCES}
18+
)
19+
20+
# Find packages - ReactAndroid provides JSI headers
21+
find_package(ReactAndroid REQUIRED CONFIG)
22+
find_package(fbjni REQUIRED CONFIG)
23+
find_package(react-native-executorch REQUIRED CONFIG)
24+
25+
# Include headers
26+
target_include_directories(
27+
${CMAKE_PROJECT_NAME}
28+
PRIVATE
29+
"${RN_EXECUTORCH_DIR}/common"
30+
"${RN_EXECUTORCH_THIRD_PARTY}"
31+
)
32+
33+
# Find prebuilt libraries
34+
find_library(LOG_LIB log)
35+
find_library(ANDROID_LIB android)
36+
37+
# Import ExecuTorch library
38+
set(LIBS_DIR "${RN_EXECUTORCH_DIR}/third-party/android/libs")
39+
add_library(executorch SHARED IMPORTED)
40+
set_target_properties(executorch PROPERTIES
41+
IMPORTED_LOCATION "${LIBS_DIR}/executorch/${ANDROID_ABI}/libexecutorch.so")
42+
43+
# OpenCV libraries
44+
set(OPENCV_LIBS
45+
"${LIBS_DIR}/opencv/${ANDROID_ABI}/libopencv_core.a"
46+
"${LIBS_DIR}/opencv/${ANDROID_ABI}/libopencv_imgproc.a"
47+
)
48+
49+
# OpenCV third-party libraries (arm64 specific)
50+
if(ANDROID_ABI STREQUAL "arm64-v8a")
51+
set(OPENCV_THIRD_PARTY_LIBS
52+
"${LIBS_DIR}/opencv-third-party/${ANDROID_ABI}/libkleidicv_hal.a"
53+
"${LIBS_DIR}/opencv-third-party/${ANDROID_ABI}/libkleidicv_thread.a"
54+
"${LIBS_DIR}/opencv-third-party/${ANDROID_ABI}/libkleidicv.a"
55+
)
56+
else()
57+
set(OPENCV_THIRD_PARTY_LIBS "")
58+
endif()
59+
60+
# OpenMP for OpenCV parallel operations
61+
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -fopenmp -static-openmp)
62+
63+
# Link against libraries
64+
target_link_libraries(
65+
${CMAKE_PROJECT_NAME}
66+
${LOG_LIB}
67+
${ANDROID_LIB}
68+
ReactAndroid::jsi
69+
ReactAndroid::reactnative
70+
fbjni::fbjni
71+
react-native-executorch::react-native-executorch
72+
${OPENCV_LIBS}
73+
${OPENCV_THIRD_PARTY_LIBS}
74+
executorch
75+
)
76+
77+
# Ensure react-native-executorch symbols are exported (needed for BaseModel/BaseSemanticSegmentation)
78+
target_link_options(${CMAKE_PROJECT_NAME} PRIVATE -Wl,--no-as-needed)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
buildscript {
2+
ext.kotlin_version = '1.9.0'
3+
4+
repositories {
5+
google()
6+
mavenCentral()
7+
}
8+
9+
dependencies {
10+
classpath 'com.android.tools.build:gradle:8.2.2'
11+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
12+
}
13+
}
14+
15+
apply plugin: 'com.android.library'
16+
apply plugin: 'kotlin-android'
17+
18+
android {
19+
namespace 'com.executorch.webrtc'
20+
compileSdkVersion 34
21+
22+
buildFeatures {
23+
prefab true
24+
}
25+
26+
defaultConfig {
27+
minSdkVersion 26
28+
targetSdkVersion 34
29+
30+
externalNativeBuild {
31+
cmake {
32+
cppFlags "-std=c++20"
33+
arguments "-DANDROID_STL=c++_shared"
34+
}
35+
}
36+
37+
ndk {
38+
// Only build for architectures where OpenCV/ExecuTorch libraries exist
39+
abiFilters 'arm64-v8a'
40+
}
41+
}
42+
43+
externalNativeBuild {
44+
cmake {
45+
path "CMakeLists.txt"
46+
}
47+
}
48+
49+
compileOptions {
50+
sourceCompatibility JavaVersion.VERSION_17
51+
targetCompatibility JavaVersion.VERSION_17
52+
}
53+
54+
kotlinOptions {
55+
jvmTarget = '17'
56+
}
57+
58+
packaging {
59+
jniLibs {
60+
// Don't bundle react-native-executorch.so - the app gets it from the main package
61+
excludes += ['**/libreact-native-executorch.so']
62+
}
63+
}
64+
}
65+
66+
repositories {
67+
google()
68+
mavenCentral()
69+
}
70+
71+
dependencies {
72+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
73+
implementation 'com.facebook.react:react-native:+'
74+
75+
// WebRTC classes - provided by app via autolinking
76+
compileOnly project(':fishjam-cloud_react-native-webrtc')
77+
// ExecuTorch for vision model processing.
78+
// Must be `implementation` (not compileOnly) so AGP exposes the producer's
79+
// prefab .so to our CMake link step. The packaging.jniLibs.excludes block
80+
// above prevents double-bundling — the app gets the .so from the main pkg.
81+
implementation project(':react-native-executorch')
82+
}
Binary file not shown.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
distributionBase=GRADLE_USER_HOME
2+
distributionPath=wrapper/dists
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip
4+
networkTimeout=10000
5+
validateDistributionUrl=true
6+
zipStoreBase=GRADLE_USER_HOME
7+
zipStorePath=wrapper/dists

0 commit comments

Comments
 (0)