Blur or replace the camera background Mobile
This guide is exclusively for Mobile (React Native) applications.
@fishjam-cloud/video-effects ships ready-made background effects: background blur and background image replacement. An effect runs on the camera track Fishjam already publishes, as a camera track middleware. Your app keeps using useCamera, and other peers keep receiving your video as peer.cameraTrack, now with the effect applied.
Each camera frame reaches a worklet on a dedicated camera thread, a segmentation model finds the person on the GPU, and the effect draws the result into the published frame. The JS thread is not involved per frame. See How camera effects work for the details.
The background blur tutorial builds this step by step in a working app, from installing the packages to a blur toggle.
Prerequisites
@fishjam-cloud/react-native-client0.30.2 or newer, with your app wrapped inFishjamProvider(see Installation)@fishjam-cloud/video-effects0.1.5 or newerreact-native-webgpu0.10.1 or newer. Older versions leak one camera frame of graphics memory per frame on Android until the JavaScript garbage collector runs.- React Native 0.86 (Expo SDK 57) with the New Architecture enabled
- iOS 16.4 or newer, or Android 8.0 (API 26) or newer
- A physical device, or the iOS Simulator with a virtual camera from SimCam
Install
- npm
- Yarn
- pnpm
- Bun
npm install @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu expo-asset expo-file-system expo-build-properties npm install --save-dev unplugin-typegpu @babel/plugin-transform-class-static-block
yarn add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu expo-asset expo-file-system expo-build-properties yarn add --dev unplugin-typegpu @babel/plugin-transform-class-static-block
pnpm add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu expo-asset expo-file-system expo-build-properties pnpm add --save-dev unplugin-typegpu @babel/plugin-transform-class-static-block
bun add @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu expo-asset expo-file-system expo-build-properties bun add --dev unplugin-typegpu @babel/plugin-transform-class-static-block
What each package does:
@fishjam-cloud/video-effects: the effects, the segmentation model and the camera middleware@fishjam-cloud/react-native-worklets: runs a worklet on every frame of the Fishjam camera track. Its minor version followsreact-native-worklets:0.12.xworks withreact-native-worklets0.12.x.react-native-webgpu: the GPU the effects render withexpo-assetandexpo-file-system: load the bundled segmentation modelexpo-build-properties: raises the Android minimum SDK version
Configure Babel
The effects rely on the react-native-worklets Babel plugin, and on TypeGPU, which needs import.meta, class static blocks and its own Babel plugin. Keep react-native-worklets/plugin as the last plugin:
module.exports = function (api) { api.cache(true); return { presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]], plugins: [ "@babel/plugin-transform-class-static-block", "unplugin-typegpu/babel", "react-native-worklets/plugin", ], }; };
Configure Metro
The segmentation model ships as a .ssgbin file. Add the extension to Metro's asset extensions so it can be bundled with your app:
const { getDefaultConfig } = require("expo/metro-config"); const config = getDefaultConfig(__dirname); config.resolver.assetExts = [...config.resolver.assetExts, "ssgbin"]; module.exports = config;
Set the minimum OS versions
Expo SDK 57 needs iOS 16.4, while the Fishjam config plugin sets 15.1 by default. react-native-webgpu needs Android 8.0 (API 26), while Expo defaults to API 24. Raise both in app.json, the Android one with expo-build-properties:
{ "expo": { "plugins": [ [ "@fishjam-cloud/react-native-client", { "ios": { "iphoneDeploymentTarget": "16.4" } } ], [ "expo-build-properties", { "android": { "minSdkVersion": 26 } } ] ] } }
In a bare React Native app, set platform :ios, '16.4' in ios/Podfile and minSdkVersion = 26 in android/build.gradle instead.
Then restart Metro with a cleared cache and rebuild the native app, since the new packages contain native code:
npx expo prebuild npx expo run:ios # or run:android
Load the segmentation model
Both effects need a segmentation provider, which finds the person in each frame. typeGpuPersonSegmentation runs the bundled model on the GPU. Create the provider once, at module scope, so every effect that uses it shares one loaded model:
import {typeGpuPersonSegmentation } from "@fishjam-cloud/video-effects/segmentation/typegpu"; import {Asset } from "expo-asset"; import {File } from "expo-file-system"; constsegmentationModel =Asset .fromModule (require ("@fishjam-cloud/video-effects/assets/selfie_segmenter.ssgbin"), ); async functionloadSegmentationModel ():Promise <ArrayBuffer > { awaitsegmentationModel .downloadAsync (); if (!segmentationModel .localUri ) { throw newError ("The segmentation model is not available."); } return newFile (segmentationModel .localUri ).arrayBuffer (); } export constsegmentation =typeGpuPersonSegmentation ({loadModel :loadSegmentationModel , });
The model is read from a local copy of the asset instead of being fetched by URL, because an Android release build cannot fetch a bundled asset. If you host the model yourself, pass its address as modelUrl instead of loadModel.
Blur the background
Wrap the effect in createCameraEffectMiddleware and keep the middleware at module scope. A stable identity lets you tell whether it is active by comparing it with currentCameraMiddleware:
import {createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur"; import {createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native"; export constbackgroundBlur =createCameraEffectMiddleware (createBackgroundBlurEffect (() => ({segmentation ,radius : 24 })), );
Then switch it on and off with setCameraTrackMiddleware:
importReact from "react"; import {Button } from "react-native"; import {useCamera } from "@fishjam-cloud/react-native-client"; export functionBlurToggle () { const {currentCameraMiddleware ,setCameraTrackMiddleware } =useCamera (); constisBlurOn =currentCameraMiddleware ===backgroundBlur ; consttoggleBlur = async () => { try { awaitsetCameraTrackMiddleware (isBlurOn ? null :backgroundBlur ); } catch (error ) {console .warn ("Background blur failed",error ); awaitsetCameraTrackMiddleware (null); } }; return ( <Button title ={isBlurOn ? "Blur off" : "Blur on"}onPress ={toggleBlur } /> ); }
How it behaves:
- The middleware lives in Fishjam's camera state, not in the component. It stays on across screens until you pass
null, and it is applied again when the camera restarts or you switch cameras. - You can set it before the camera starts. It is applied as soon as the camera track exists.
- Setting it up takes a moment: the model loads and the GPU pipelines are built. Until the blurred track is ready, peers keep receiving the previous track, so the video never goes black.
- If setting up fails, for example when the device has no suitable GPU,
setCameraTrackMiddlewarerejects.currentCameraMiddlewarealready points at the failed middleware at that point, so passnullto go back to the plain camera, as in the example.
Replace the background with an image
createBackgroundImageEffect draws an image behind the person instead of blurring. Pass the image as a uri, or as data with its bytes. Background images need @fishjam-cloud/video-effects 0.1.4 or newer; on React Native, earlier versions publish the camera without the image:
import {createBackgroundImageEffect } from "@fishjam-cloud/video-effects/background-image"; import {createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native"; export constbeachBackground =createCameraEffectMiddleware (createBackgroundImageEffect (() => ({segmentation ,image : {uri : "https://example.com/beach.jpg" },fit : "cover", })), );
The image is downloaded and decoded while the middleware sets up. If it cannot be loaded, the middleware reports an "error" status and publishes the camera without the effect.
Use a bundled image
An Android release build cannot fetch a bundled asset, so read the bytes of a bundled image yourself and pass them as data. expo-asset keeps a bundled image as a drawable resource on Android release builds, so describe the asset again without its image size, which makes downloadAsync copy it to a local file:
import {createBackgroundImageEffect } from "@fishjam-cloud/video-effects/background-image"; import {createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native"; import {Asset } from "expo-asset"; import {File } from "expo-file-system"; async functionloadBundledImage (moduleId : number):Promise <ArrayBuffer > { constbundled =Asset .fromModule (moduleId ); constasset = newAsset ({name :bundled .name ,type :bundled .type ,hash :bundled .hash ,uri :bundled .uri , }); awaitasset .downloadAsync (); if (!asset .localUri ) throw newError ("The image is not available."); return newFile (asset .localUri ).arrayBuffer (); } letofficeImage :ArrayBuffer | undefined; export constofficeImageLoaded =loadBundledImage (require ("./assets/office.jpg"), ).then ((bytes ) => {officeImage =bytes ; }); export constofficeBackground =createCameraEffectMiddleware (createBackgroundImageEffect (() => ({segmentation ,image : {data :officeImage ,mimeType : "image/jpeg" }, })), );
The options are read when the middleware is applied, so switch it on after officeImageLoaded resolves.
Options
Effect options
The options function passed to createBackgroundBlurEffect or createBackgroundImageEffect is read when the middleware is applied. To change an option, create a middleware with the new options and set it. Setting the same middleware again also re-reads its options.
| Option | Effect | Default | Description |
|---|---|---|---|
segmentation | Both | — | The segmentation provider. Required. |
radius | Blur | 18 | Blur strength, in pixels of the published frame, from 0 to 40. |
edgeFeather | Both | 0.2 | Softness of the person's outline, from 0 (sharp) to 0.5. Defaults to 0.08 for images. |
enabled | Both | true | Draws the camera untouched while false. |
image | Image | — | { uri } or { data, mimeType }. Required. |
fit | Image | "cover" | "cover" fills the frame and crops the image, "contain" fits the whole image inside the frame. |
backgroundColor | Image | [0, 0, 0, 1] | RGBA color, each channel from 0 to 1, shown where a "contain" image does not cover the frame. |
Middleware options
createCameraEffectMiddleware takes a second, optional argument:
| Option | Default | Description |
|---|---|---|
width | 720 | Width of the published video, in pixels. |
height | 1280 | Height of the published video, in pixels. |
onStatus | — | Called with the effect's status ("loading", "ready" or "error") and any error. |
The camera is scaled and cropped to fill the published size, like objectFit: "cover". The defaults suit a phone held upright.
Follow loading and errors
Pass onStatus to show a spinner while the model loads, or to report failures:
import {createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur"; import {createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native"; export constbackgroundBlur =createCameraEffectMiddleware (createBackgroundBlurEffect (() => ({segmentation ,radius : 24 })), {onStatus : (status ,error ) => { if (status === "error")console .warn ("Background blur failed",error ); }, }, );
An "error" status means the segmentation model or the background image could not be loaded. The middleware is still applied, but it publishes the camera without the effect.
Scope the effect to a component
createCameraEffectMiddleware keeps the effect on until you clear it. If the effect should only be on while a component is mounted, for example on a single call screen, use the useFishjamCameraEffect hook instead. It applies the effect while mounted, clears it on unmount, and reports the status as state:
import {segmentation } from "./effects"; importReact , {useState } from "react"; import {Button ,Text } from "react-native"; import {useBackgroundBlur } from "@fishjam-cloud/video-effects/background-blur"; import {useFishjamCameraEffect } from "@fishjam-cloud/video-effects/fishjam-react-native"; export functionCallControls () { const [isBlurOn ,setIsBlurOn ] =useState (false); constblur =useBackgroundBlur ({segmentation ,radius : 24 }); const {status ,error ,retry } =useFishjamCameraEffect (isBlurOn ?blur : null, ); return ( <> <Button title ={isBlurOn ? "Blur off" : "Blur on"}onPress ={() =>setIsBlurOn ((value ) => !value )} /> {status === "loading" && <Text >Loading blur…</Text >} {error && <Button title ="Retry"onPress ={retry } />} </> ); }
While the effect loads, the hook publishes the plain camera. useBackgroundImage is the matching hook for image backgrounds.
The camera has a single middleware slot. useFishjamCameraEffect and setCameraTrackMiddleware write to the same slot, so don't mix them, or they replace each other's effect.
Good to know
- Effects improve how the video looks. They are not a privacy boundary: the model can miss parts of the background, for example around the edges of the person or in poor light, and let them show through.
- Remote peers need nothing special. The effect is part of the published camera track.
- The local preview from
useCamera().cameraStreamshows the effect too, because it renders the published track.
Related guides
- Background blur tutorial: build a blur toggle step by step
- Render WebGPU effects into the camera: draw your own shaders instead of a ready-made effect
- Process camera frames in a worklet: read camera frames, for example for on-device ML
- How camera effects work
- API reference:
createCameraEffectMiddleware,useFishjamCameraEffect,typeGpuPersonSegmentation, Video Effects package