# react-native-webrtc-kaleidoscope > Live, on-device video effects for `react-native-webrtc`, packaged as a managed-Expo-friendly Expo Module. Works on web, Android, and iOS behind one JS facade. Every effect is a **composite**: a back-to-front stack of layers (a bundled image, the masked person, a camera blur, or a generative GLSL shader) rendered into the output frame. You drive it with three verbs: `kaleidoscope` (the background/art), `transform` (flip/rotate), and `mask` (the segmentation edge). This file is written for an LLM integrating the library into a consumer app. Follow it top to bottom and you can install it, configure the native build, provision a working preset book, and get an effect on screen. The repo's `demo/` directory is the canonical working example; every file in the [Starting fileset](#starting-fileset-six-files-that-run) below is a trimmed copy of a demo file that runs on all three platforms. ## What this package is A thin layer over `react-native-webrtc`'s public-but-non-standard `track._setVideoEffects([...])` API. On native (iOS, Android) it registers one frame-processor factory, `composite`, at app boot via an Expo config plugin, and delivers the layer stack out of band. On web it runs a WebGL2 layered compositor through `MediaStreamTrackProcessor` + `MediaStreamTrackGenerator` (Insertable Streams) behind the same JS facade. It is **not** a fork of `react-native-webrtc`, **not** a managed-cloud SaaS, and **not** a face-filter/AR SDK. It is a small module you install alongside `react-native-webrtc` to get background blur, background replacement, generative backgrounds, and geometric transforms on a local video track. Effects run on-device; the track stays peer-to-peer. **Workflow:** this ships native frame processors, so **Expo Go is not supported**. You need a development build (`expo-dev-client` via EAS Build, or `npx expo run:android` / `run:ios` with local toolchains). Web needs no native build at all. Tested against Expo SDK 51 / React Native 0.74; the peer ranges are open-ended above that. Package version 2.x. ## Install ```sh bun add react-native-webrtc react-native-webrtc-kaleidoscope bunx expo install expo-build-properties expo-dev-client # or npm install / yarn add / pnpm add ``` Peer dependencies: `expo` (>=51), `expo-asset` (>=10, already a dependency of `expo`), `react` (>=18.2), `react-native` (>=0.74). `react-native-webrtc` (>=124) is an **optional** peer you supply (or `@livekit/react-native-webrtc` + `livekit-client` if you use the `/livekit` adapter; pick ONE fork, never both). `nativewind` (>=4) and `@react-native-community/slider` (>=4.5) are **optional** peers, only needed for the styled UI / live-controls extras. ## Configure (copy-paste; every entry here is load-bearing) In `app.config.ts` / `app.config.js` / `app.json`: ```ts export default { expo: { plugins: [ [ 'expo-build-properties', { // react-native-webrtc requires Android API 24+; at Expo's default // minSdkVersion (23) the manifest merger rejects the WebRTC AAR and // the Android build fails. android: { minSdkVersion: 24 }, // The kaleidoscope plugin raises the Pods platform to 15.0, but the // app target's IPHONEOS_DEPLOYMENT_TARGET is set by Expo prebuild // independently; without this entry the iOS link step fails with // "compiling for iOS 13.4, but module 'Kaleidoscope' has a minimum // deployment target of iOS 15.0". ios: { deploymentTarget: '15.0' }, }, ], 'react-native-webrtc-kaleidoscope', ], ios: { infoPlist: { // Without this key iOS kills the app at the first getUserMedia call. NSCameraUsageDescription: 'The camera is used for video calls; effects are applied on-device.', // Required by react-native-webrtc even if you never record audio. NSMicrophoneUsageDescription: 'Required by react-native-webrtc for call audio.', }, }, android: { permissions: ['CAMERA', 'RECORD_AUDIO', 'MODIFY_AUDIO_SETTINGS'], }, }, }; ``` Do **not** add `'react-native-webrtc'` to `plugins`: upstream 124.x ships no config plugin and prebuild fails to resolve it. (If you are on a fork that adds one, add it explicitly, before the kaleidoscope plugin.) The kaleidoscope plugin itself handles the rest at prebuild: it patches the iOS Podfile so `react-native-webrtc` builds with modular headers, raises the Pods deployment target, and copies the images your preset book references into the native bundle. Then generate native projects and build a dev client: ```sh bunx expo prebuild bunx eas-cli build -p android --profile development # or: bunx expo run:android bunx eas-cli build -p ios --profile development # or: bunx expo run:ios ``` Web (Chromium only) runs with no prebuild: `bunx expo start --web`. ## The preset-book contract (read this before creating the file) The plugin statically analyzes ONE file to decide which image assets ship in your native bundle. The contract: 1. **Exact filename, exact location:** `kaleidoscope.preset-book.ts` at the **project root** (the directory containing `package.json` and your app config). A book named or placed differently still typechecks and still works on web, but at prebuild NO assets are copied and every image background silently renders nothing on native. This is the most common integration mistake; the failure is silent. 2. **The book is parsed as text, never executed.** Keep every `image` layer's `source` statically analyzable: a single named import from `react-native-webrtc-kaleidoscope/images//`, a `require('./local.webp')` literal, or a `const X = Asset.fromModule(require('./local.webp')).uri` binding (the binding may span lines; a formatter wrapping it at a narrow print width is fine). Do not compute sources, build layers in helper functions, or re-export the book through a barrel; the parser will not follow them, and each skipped `image` layer warns at prebuild naming the layer id. 3. **An `image` layer's `id` doubles as its native plate id**: keep it equal to the image file's basename (`office-dark`, `my-bg`). Your own background images must be `.webp` (native resolves `assets/images/.webp`). 4. **Re-run `bunx expo prebuild` after editing the book** (adding/deleting presets changes the native asset set). JS-only uniform tweaks hot-reload without it. ## Starting fileset (six files that run) These are trimmed copies of the demo's working files (`demo/kaleidoscope.preset-book.ts`, `demo/src/`, `demo/app/index.tsx` in the repo). Create all six, run a dev build once, and you have the full catalog on screen behind a picker. The end user then opts out by DELETING preset entries (and their now-unused imports) from the book; nothing else references them. ### 1. `kaleidoscope.preset-book.ts` (project root; exact name; see contract above) ```ts import type { KaleidoscopePresetBook } from 'react-native-webrtc-kaleidoscope'; // Packaged multi-layer scenes. Each carries its own taxonomy and thumbnail; // delete the import + the entry to opt out of one. import { clouds } from 'react-native-webrtc-kaleidoscope/composites/clouds'; import { corporateBlobs } from 'react-native-webrtc-kaleidoscope/composites/corporate-blobs'; import { fairyCave } from 'react-native-webrtc-kaleidoscope/composites/fairy-cave'; import { fairyGrotto } from 'react-native-webrtc-kaleidoscope/composites/fairy-grotto'; import { fairyHollow } from 'react-native-webrtc-kaleidoscope/composites/fairy-hollow'; import { nebula } from 'react-native-webrtc-kaleidoscope/composites/nebula'; import { observationDeck } from 'react-native-webrtc-kaleidoscope/composites/observation-deck'; import { simianlights } from 'react-native-webrtc-kaleidoscope/composites/simianlights'; import { underwater } from 'react-native-webrtc-kaleidoscope/composites/underwater'; import { wizardTower } from 'react-native-webrtc-kaleidoscope/composites/wizard-tower'; import { wizardTowerNight } from 'react-native-webrtc-kaleidoscope/composites/wizard-tower-night'; // Bundled background images, one subpath per image. An unused import is dead // weight at most: web tree-shakes it, native never copies it. import { homeDark } from 'react-native-webrtc-kaleidoscope/images/home/home-dark'; import { homeLight } from 'react-native-webrtc-kaleidoscope/images/home/home-light'; import { landscapeDark } from 'react-native-webrtc-kaleidoscope/images/nature/landscape-dark'; import { landscapeLight } from 'react-native-webrtc-kaleidoscope/images/nature/landscape-light'; import { officeDark } from 'react-native-webrtc-kaleidoscope/images/office/office-dark'; import { officeLight } from 'react-native-webrtc-kaleidoscope/images/office/office-light'; import { sciFiLight } from 'react-native-webrtc-kaleidoscope/images/sci-fi/sci-fi-light'; import { oceanscapeDark } from 'react-native-webrtc-kaleidoscope/images/underwater/oceanscape-dark'; // To add YOUR OWN background, use an analyzable require literal on a local WebP // and give the image layer an id equal to the file basename: // import { Asset } from 'expo-asset'; // const myBg = Asset.fromModule(require('./assets/my-bg.webp')).uri; // ...then an entry shaped exactly like the image presets below. export const presets = { // --- Effects / Blur: the camera blurred behind you, you sharp on top. --- 'blur-low': { name: 'Low', taxonomy: ['Effects', 'Blur'], layers: [ { id: 'blur', shader: 'blur', target: 'background', uniforms: { sigma: 1.5 } }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'blur-medium': { name: 'Medium', taxonomy: ['Effects', 'Blur'], layers: [ { id: 'blur', shader: 'blur', target: 'background', uniforms: { sigma: 3.75 } }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'blur-high': { name: 'High', taxonomy: ['Effects', 'Blur'], layers: [ { id: 'blur', shader: 'blur', target: 'background', uniforms: { sigma: 8 } }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, // --- Worlds: packaged scenes, spread in as-is. One line each; delete freely. --- 'wizard-tower': wizardTower, 'wizard-tower-night': wizardTowerNight, 'observation-deck': observationDeck, 'fairy-cave': fairyCave, 'fairy-grotto': fairyGrotto, 'fairy-hollow': fairyHollow, underwater: underwater, 'corporate-blobs': corporateBlobs, clouds: clouds, nebula: nebula, simianlights: simianlights, // --- Backgrounds: one image (cover-fit), you composited over it. The image // layer's id MUST stay the image basename. --- 'office-dark': { name: 'Office (Dark)', taxonomy: ['Backgrounds', 'Office'], thumbnail: officeDark, layers: [ { id: 'office-dark', shader: 'image', source: officeDark }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'office-light': { name: 'Office (Light)', taxonomy: ['Backgrounds', 'Office'], thumbnail: officeLight, layers: [ { id: 'office-light', shader: 'image', source: officeLight }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'home-dark': { name: 'Home (Dark)', taxonomy: ['Backgrounds', 'Home'], thumbnail: homeDark, layers: [ { id: 'home-dark', shader: 'image', source: homeDark }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'home-light': { name: 'Home (Light)', taxonomy: ['Backgrounds', 'Home'], thumbnail: homeLight, layers: [ { id: 'home-light', shader: 'image', source: homeLight }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'landscape-dark': { name: 'Landscape (Dark)', taxonomy: ['Backgrounds', 'Nature'], thumbnail: landscapeDark, layers: [ { id: 'landscape-dark', shader: 'image', source: landscapeDark }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'landscape-light': { name: 'Landscape (Light)', taxonomy: ['Backgrounds', 'Nature'], thumbnail: landscapeLight, layers: [ { id: 'landscape-light', shader: 'image', source: landscapeLight }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'sci-fi-light': { name: 'Sci-Fi', taxonomy: ['Backgrounds', 'Sci-Fi'], thumbnail: sciFiLight, layers: [ { id: 'sci-fi-light', shader: 'image', source: sciFiLight }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'oceanscape-dark': { name: 'Oceanscape', taxonomy: ['Backgrounds', 'Underwater'], thumbnail: oceanscapeDark, layers: [ { id: 'oceanscape-dark', shader: 'image', source: oceanscapeDark }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, // --- Shaders: a generative GLSL field behind you; the inline-authoring // pattern for any shader in the catalog. --- 'plasma-ocean': { name: 'Ocean', taxonomy: ['Shaders', 'Plasma'], layers: [ { id: 'plasma', shader: 'plasma', uniforms: { uColorA: [0.0, 0.3, 0.6], uColorB: [0.0, 0.6, 0.6], uSpeed: 0.3, uScale: 8 }, }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, 'plasma-sunset': { name: 'Sunset', taxonomy: ['Shaders', 'Plasma'], layers: [ { id: 'plasma', shader: 'plasma', uniforms: { uColorA: [0.9, 0.3, 0.1], uColorB: [0.8, 0.1, 0.5], uSpeed: 0.3, uScale: 8 }, }, { id: 'you', shader: 'direct', target: 'subject' }, ], }, } as const satisfies KaleidoscopePresetBook; export type PresetId = keyof typeof presets; ``` ### 2 + 3. `src/use-loopback-stream.ts` and `src/use-loopback-stream.web.ts` Platform split by file extension (Metro picks `.web.ts` for web); the pair shares one shape. Native: ```ts // src/use-loopback-stream.ts import { useEffect, useState } from 'react'; import { type MediaStream, mediaDevices } from 'react-native-webrtc'; type StreamState = | { status: 'idle' } | { status: 'pending' } | { status: 'ready'; stream: MediaStream } | { status: 'error'; error: Error }; export const useLoopbackStream = (): StreamState => { const [state, setState] = useState({ status: 'idle' }); useEffect(() => { let cancelled = false; let acquired: MediaStream | null = null; setState({ status: 'pending' }); mediaDevices .getUserMedia({ video: { facingMode: 'user' }, audio: false }) .then((raw) => { const stream = raw as unknown as MediaStream; if (cancelled) { for (const t of stream.getTracks()) t.stop(); return; } acquired = stream; setState({ status: 'ready', stream }); }) .catch((err: unknown) => { if (cancelled) return; setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) }); }); return () => { cancelled = true; if (acquired) for (const t of acquired.getTracks()) t.stop(); }; }, []); return state; }; ``` Web is identical except the import line goes away and the call is `navigator.mediaDevices.getUserMedia({ video: true, audio: false })` on the browser's own `MediaStream` type (no cast needed). ### 4 + 5. `src/video-preview.tsx` and `src/video-preview.web.tsx` ```tsx // src/video-preview.tsx (native): RTCView over a wrapping MediaStream. import { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { MediaStream, type MediaStreamTrack as RNWebRTCMediaStreamTrack, RTCView, } from 'react-native-webrtc'; export const VideoPreview = ({ track }: { track: MediaStreamTrack | null }) => { const streamUrl = useMemo(() => { if (!track) return null; return new MediaStream([track as unknown as RNWebRTCMediaStreamTrack]).toURL(); }, [track]); if (!streamUrl) return ; return ; }; const styles = StyleSheet.create({ box: { aspectRatio: 4 / 3, backgroundColor: '#1a1a1a', borderRadius: 8 }, }); ``` ```tsx // src/video-preview.web.tsx: an HTML