diff --git a/.gitignore b/.gitignore
index c6065300..9eb2b0f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -140,5 +140,7 @@ docs/api/server/**
docs/api/server-python/**
docs/api/custom-video-source/**
docs/api/vision-camera-source/**
+docs/api/video-effects/**
+docs/api/react-native-worklets/**
.cursor/
diff --git a/.gitmodules b/.gitmodules
index f9ec1a7a..d1480a8a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -19,3 +19,9 @@
[submodule "api/composition"]
path = api/composition
url = git@github.com:fishjam-cloud/foundry.git
+[submodule "packages/video-effects"]
+ path = packages/video-effects
+ url = https://github.com/fishjam-cloud/video-effects.git
+[submodule "packages/react-native-worklets"]
+ path = packages/react-native-worklets
+ url = https://github.com/fishjam-cloud/fishjam-react-native-worklets.git
diff --git a/docs/examples/react-native.mdx b/docs/examples/react-native.mdx
index 762dc616..9bda5114 100644
--- a/docs/examples/react-native.mdx
+++ b/docs/examples/react-native.mdx
@@ -115,6 +115,10 @@ The blur hook from `useBackgroundBlur` provides a `middleware` function that is
Browse the full source: [blur-example on GitHub](https://github.com/fishjam-cloud/examples/tree/main/mobile-react-native/blur-example)
+:::tip
+For new apps, use `@fishjam-cloud/video-effects`, which blurs or replaces the background on the GPU on both iOS and Android. See [Background effects](../how-to/client/camera-effects/background-effects.mdx) and the [background blur tutorial](../tutorials/background-blur.mdx).
+:::
+
---
## Text Chat
diff --git a/docs/explanation/camera-effects.mdx b/docs/explanation/camera-effects.mdx
new file mode 100644
index 00000000..71ac8c3a
--- /dev/null
+++ b/docs/explanation/camera-effects.mdx
@@ -0,0 +1,100 @@
+---
+title: "How camera effects work"
+sidebar_position: 9.5
+description: Concepts behind Fishjam camera effects in React Native, including track middleware, the camera frame tap, the worklet runtime and the WebGPU render pipeline.
+---
+
+# How camera effects work
+
+[Camera effects](../how-to/client/camera-effects/index.mdx) change the camera video a React Native app publishes: a blurred background, a background image, or anything your own shaders draw. This page explains where effects plug into the Fishjam client, how camera frames reach a worklet and the GPU without copying pixels, and how effects such as background blur are structured.
+
+## Middleware, custom source, or VisionCamera?
+
+Fishjam clients offer three ways to publish processed camera video:
+
+| Approach | What it does | When to use it |
+| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
+| [Camera track middleware](../how-to/client/camera-effects/stream-middleware.mdx) | Replaces the camera track Fishjam captures with a processed one before it's sent | Effects on the regular camera; peers receive them as your `cameraTrack` |
+| [Custom sources](../how-to/client/custom-sources/index.mdx) | Publishes a `MediaStream` your app produced on its own as a `customVideo` track | Content that isn't the managed camera: renderers, compositors, file playback |
+| [VisionCamera integration](../integrations/vision-camera/vision-camera-source.mdx) | Publishes a VisionCamera feed as a custom source | Your app already depends on VisionCamera for capture, device control or plugins |
+
+Middleware is the default for effects: device selection, permissions, starting, stopping and switching cameras stay with [`useCamera`](../api/mobile/functions/useCamera), and the receiving side needs no changes.
+
+## Where effects plug in
+
+A camera track middleware is a function from the raw camera track to the track that is published instead. The Fishjam client owns when it runs:
+
+- **Every new device track goes through it.** The middleware runs when the camera starts, restarts or is switched, so the raw track is never published while a middleware is set.
+- **Publish first, release second.** When a middleware is set or replaced, the client builds the new track, swaps it into the published stream, and only then releases the previous one. Releasing a React Native track disposes it natively, so the order keeps the published stream from ever holding a dead track.
+- **The latest request wins.** If a middleware is replaced while it is still setting up, it releases itself as soon as it finishes and is never published. Until a new track is ready, peers keep receiving the previous one.
+
+The middleware and its `onClear` live in the client's camera state, not in a component, which is why an effect stays on across screens until it is cleared with `null`.
+
+## The camera frame pipeline
+
+An effect needs every camera frame, on the GPU, without slowing down the JS thread. React Native has no built-in way to do that, so the pipeline is split across three packages:
+
+```mermaid
+flowchart LR
+ camera["Camera capturer (react-native-webrtc)"] --> tap["Camera frame processor admits one frame at a time"]
+ tap --> runtime["Camera frame runtime (react-native-worklets)"]
+ runtime --> kernel["Your frame kernel or a video effect"]
+ kernel --> gpu["WebGPU render into a pooled output surface"]
+ gpu --> track["Pooled video track"]
+ track --> room["Published as your cameraTrack"]
+```
+
+### The camera tap
+
+`@fishjam-cloud/react-native-webrtc` exposes a **camera frame processor** for every local camera track, returned by `getCameraFrameProcessor`. A consumer attached to it receives the frames the camera captures, as native buffers: `CVPixelBufferRef`s on iOS and `AHardwareBuffer`s on Android.
+
+Frames are admitted one at a time. While a consumer still holds a frame, newer frames are dropped on the capture thread instead of being queued, so a slow effect lowers its own frame rate but never builds up latency.
+
+### The worklet runtime
+
+`@fishjam-cloud/react-native-worklets` is the consumer. `attachCameraFrameCallback` calls a worklet for every admitted frame on a dedicated **camera frame runtime**: a separate JS runtime with its own thread, created once for the whole app. The JS thread is not involved per frame, and the buffer handoff is a synchronous JSI call, which is why the pipeline requires the New Architecture.
+
+A worklet's closure is copied to that runtime when it is attached. Anything the per-frame code needs, such as GPU pipelines, bind groups and plain numbers, must be created up front on the JS thread and captured.
+
+### The render pipeline
+
+`createCameraFrameProcessorSession` in `@fishjam-cloud/video-effects` turns the tap into a published track:
+
+- It allocates a pool of three GPU-shareable output surfaces and a custom video track that reads from them. One surface can be encoding while the next is being drawn.
+- For every frame, it imports the camera buffer into WebGPU as an external texture, already rotated upright, and hands your frame kernel a context with that texture, a command encoder and the next output surface.
+- After the kernel has encoded its passes, it submits them once and pushes the surface to the track with a GPU fence, so the video encoder waits for the drawing to finish instead of reading a half-drawn frame. The frame keeps the camera's timestamp.
+
+Pixels stay in native GPU memory from the camera to the encoder; only handles move between the camera, the worklet and the track. The session's track is what the middleware returns, and its `dispose` detaches the tap and frees the pool.
+
+## Effects, sessions and frame kernels
+
+The ready-made effects use the same pipeline. An effect is described in three layers:
+
+- A **video effect** (`createBackgroundBlurEffect`, `createBackgroundImageEffect`) is a plain descriptor: an ID, the segmentation provider it needs, and a function that reads its options.
+- Creating the effect for a device builds a **session** on the JS thread: GPU textures, pipelines and, for background effects, the prepared segmentation model.
+- The session exposes a **frame kernel**: its GPU objects as plain data, plus worklet functions that encode one frame. Plain data is what lets the kernel be copied into the camera frame runtime.
+
+`createCameraEffectMiddleware` connects the layers: when the middleware runs it creates the session, captures its frame kernel and the effect's current options, and starts a frame processor session whose kernel calls the effect for every frame. Because the kernel works on a copy, options are read once, when the middleware is applied.
+
+### Segmentation
+
+Background effects need to know which pixels belong to the person. The segmentation provider from `typeGpuPersonSegmentation` runs a selfie segmentation model entirely on the GPU, written with [TypeGPU](https://docs.swmansion.com/TypeGPU/):
+
+- The model file is loaded and parsed once per model source and shared by every session that uses the same provider; each session builds its own GPU pipelines for it.
+- For each frame, the effect first offers the camera texture to the provider, which encodes the inference into the same command encoder as the effect. The mask the effect composites with therefore belongs to the same frame.
+- The effect then blurs the background, or draws the image behind the person, and softens the outline with the mask. When no recent mask is available, for example right after the model has loaded, the frame is published unchanged.
+
+If the model can't be loaded, the session is still created and reports an `"error"` status, and the effect publishes the camera as it is. A failure to create the session at all, for example on a device without the required GPU features, rejects the middleware instead.
+
+## Platform foundations
+
+- **iOS**: camera buffers are imported directly as multi-planar external textures, and output surfaces are IOSurface-backed BGRA8 (`bgra8unorm`). The shared GPU device is requested with the `dawn-multi-planar-formats` feature.
+- **Android**: the camera tap converts each camera image to an RGBA `AHardwareBuffer` before handing it over, and output surfaces are RGBA8 (`rgba8unorm`). Hardware buffers require Android 8.0 (API 26).
+- On both platforms, frame kernels sample the camera as RGB, so shaders written against `createCameraShaderBindings` with `cameraPixelLayout: "rgb"` work unchanged.
+
+## Where to go next
+
+- [Camera effects how-to guides](../how-to/client/camera-effects/index.mdx): background effects, your own WebGPU effects, frame processing and middleware
+- [Background blur tutorial](../tutorials/background-blur.mdx) and [WebGPU effects tutorial](../tutorials/webgpu-effects.mdx)
+- [How custom sources work](./custom-sources.mdx): the custom video track pipeline the render sessions build on
+- API reference: [Video Effects package](../api/video-effects/index.md), [React Native Worklets package](../api/react-native-worklets/index.md)
diff --git a/docs/explanation/custom-sources.mdx b/docs/explanation/custom-sources.mdx
index ae979605..1cf6ed08 100644
--- a/docs/explanation/custom-sources.mdx
+++ b/docs/explanation/custom-sources.mdx
@@ -12,11 +12,11 @@ description: Concepts behind Fishjam custom sources, including track metadata ro
Fishjam clients offer three ways to influence what a peer publishes:
-| Approach | What it does | When to use it |
-| ----------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
-| Built-in sources (`useCamera`, `useMicrophone`, `useScreenShare`) | Capture and publish a device the SDK manages end-to-end | Plain camera, microphone or screen sharing |
-| [Track middleware](../how-to/client/stream-middleware) | Transforms a built-in track before it is sent (e.g. background blur) | You want the SDK's device handling, but with a processing step in between |
-| [Custom sources](../how-to/client/custom-sources/index.mdx) | Publishes a `MediaStream` your app produced entirely on its own | Content that isn't a managed device: renderers, compositors, ML pipelines |
+| Approach | What it does | When to use it |
+| --------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
+| Built-in sources (`useCamera`, `useMicrophone`, `useScreenShare`) | Capture and publish a device the SDK manages end-to-end | Plain camera, microphone or screen sharing |
+| [Track middleware](../how-to/client/camera-effects/stream-middleware) | Transforms a built-in track before it is sent (e.g. background blur) | You want the SDK's device handling, but with a processing step in between |
+| [Custom sources](../how-to/client/custom-sources/index.mdx) | Publishes a `MediaStream` your app produced entirely on its own | Content that isn't a managed device: renderers, compositors, ML pipelines |
Capabilities differ slightly per platform:
@@ -27,6 +27,8 @@ Capabilities differ slightly per platform:
| Custom audio | β
any audio track | β
PCM pushed through `useCustomAudioSource` |
| GPU rendering into the published video | β
WebGPU/WebGL via canvas capture | β
WebGPU toolkit with zero-copy camera import |
+In React Native, effects on the camera, such as background blur or your own WebGPU shaders, run as track middleware on Fishjam's own camera; see [How camera effects work](./camera-effects.mdx). The custom video track pipeline described below is what those effects publish through.
+
## Streams and source IDs
A custom source is a `MediaStream` registered under a stable **source ID** with `useCustomSource(sourceId)`. The ID makes the source addressable:
@@ -86,5 +88,7 @@ The native layer re-paces pushes into the continuous real-time frame stream the
## Where to go next
-- [Custom sources how-to guides](../how-to/client/custom-sources/index.mdx): publish from the web, React Native, Vision Camera, WebGPU, or your own pipeline
+- [Custom sources how-to guides](../how-to/client/custom-sources/index.mdx): publish from the web, React Native, or your own pipeline
+- [How camera effects work](./camera-effects.mdx): effects on Fishjam's own camera
+- [VisionCamera integration](../integrations/vision-camera/vision-camera-source.mdx): publish a VisionCamera feed
- API reference: [`useCustomSource` (Web)](../api/web/functions/useCustomSource), [`useCustomSource` (React Native)](../api/mobile/functions/useCustomSource), [`useCustomAudioSource` (React Native)](../api/mobile/functions/useCustomAudioSource), [Vision Camera Source package](../api/vision-camera-source/index), [Custom Video Source package](../api/custom-video-source/index)
diff --git a/docs/how-to/client/camera-effects/_category_.json b/docs/how-to/client/camera-effects/_category_.json
new file mode 100644
index 00000000..6a3efaaf
--- /dev/null
+++ b/docs/how-to/client/camera-effects/_category_.json
@@ -0,0 +1,8 @@
+{
+ "label": "Camera effects",
+ "position": 7,
+ "link": {
+ "type": "doc",
+ "id": "how-to/client/camera-effects/index"
+ }
+}
diff --git a/docs/how-to/client/camera-effects/background-effects.mdx b/docs/how-to/client/camera-effects/background-effects.mdx
new file mode 100644
index 00000000..5e4da79b
--- /dev/null
+++ b/docs/how-to/client/camera-effects/background-effects.mdx
@@ -0,0 +1,367 @@
+---
+title: "Blur or replace the camera background"
+sidebar_position: 1
+sidebar_label: "Background effects π±"
+description: Blur or replace the background of the camera you publish to Fishjam, with ready-made effects from @fishjam-cloud/video-effects that run on the GPU.
+---
+
+# Blur or replace the camera background Mobile
+
+:::note
+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](./stream-middleware.mdx). Your app keeps using [`useCamera`](../../../api/mobile/functions/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](../../../explanation/camera-effects.mdx) for the details.
+
+:::tip[First time?]
+The [background blur tutorial](../../../tutorials/background-blur.mdx) builds this step by step in a working app, from installing the packages to a blur toggle.
+:::
+
+## Prerequisites
+
+- `@fishjam-cloud/react-native-client` **0.30.2** or newer, with your app wrapped in `FishjamProvider` (see [Installation](../installation.mdx))
+- `@fishjam-cloud/video-effects` **0.1.5** or newer
+- `react-native-webgpu` **0.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](../ios-simulator-camera.mdx)
+
+## Install
+
+```bash npm2yarn
+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
+```
+
+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 follows `react-native-worklets`: `0.12.x` works with `react-native-worklets` `0.12.x`.
+- `react-native-webgpu`: the GPU the effects render with
+- `expo-asset` and `expo-file-system`: load the bundled segmentation model
+- `expo-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:
+
+```js title='babel.config.js'
+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:
+
+```js title='metro.config.js'
+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`:
+
+```json title='app.json'
+{
+ "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:
+
+```bash
+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:
+
+```ts title='effects.ts'
+import { typeGpuPersonSegmentation } from "@fishjam-cloud/video-effects/segmentation/typegpu";
+import { Asset } from "expo-asset";
+import { File } from "expo-file-system";
+
+const segmentationModel = Asset.fromModule(
+ require("@fishjam-cloud/video-effects/assets/selfie_segmenter.ssgbin"),
+);
+
+async function loadSegmentationModel(): Promise {
+ await segmentationModel.downloadAsync();
+ if (!segmentationModel.localUri) {
+ throw new Error("The segmentation model is not available.");
+ }
+ return new File(segmentationModel.localUri).arrayBuffer();
+}
+
+export const segmentation = 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`:
+
+```ts title='effects.ts'
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+export const backgroundBlur = createCameraEffectMiddleware(
+ createBackgroundBlurEffect(() => ({ segmentation, radius: 24 })),
+);
+```
+
+Then switch it on and off with `setCameraTrackMiddleware`:
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const backgroundBlur: NonNullable;
+// ---cut---
+import React from "react";
+import { Button } from "react-native";
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+export function BlurToggle() {
+ const { currentCameraMiddleware, setCameraTrackMiddleware } = useCamera();
+ const isBlurOn = currentCameraMiddleware === backgroundBlur;
+
+ const toggleBlur = async () => {
+ try {
+ await setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur);
+ } catch (error) {
+ console.warn("Background blur failed", error);
+ await setCameraTrackMiddleware(null);
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+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, `setCameraTrackMiddleware` rejects. `currentCameraMiddleware` already points at the failed middleware at that point, so pass `null` to 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:
+
+```ts title='effects.ts'
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+import { createBackgroundImageEffect } from "@fishjam-cloud/video-effects/background-image";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+export const beachBackground = 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:
+
+```ts title='effects.ts'
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+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 function loadBundledImage(moduleId: number): Promise {
+ const bundled = Asset.fromModule(moduleId);
+ const asset = new Asset({
+ name: bundled.name,
+ type: bundled.type,
+ hash: bundled.hash,
+ uri: bundled.uri,
+ });
+ await asset.downloadAsync();
+ if (!asset.localUri) throw new Error("The image is not available.");
+ return new File(asset.localUri).arrayBuffer();
+}
+
+let officeImage: ArrayBuffer | undefined;
+export const officeImageLoaded = loadBundledImage(
+ require("./assets/office.jpg"),
+).then((bytes) => {
+ officeImage = bytes;
+});
+
+export const officeBackground = 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:
+
+```ts
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+export const backgroundBlur = 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:
+
+```tsx
+// @filename: effects.ts
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+export declare const segmentation: PersonSegmentationProvider;
+// @filename: index.tsx
+// ---cut---
+import { segmentation } from "./effects";
+import React, { 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 function CallControls() {
+ const [isBlurOn, setIsBlurOn] = useState(false);
+ const blur = useBackgroundBlur({ segmentation, radius: 24 });
+
+ const { status, error, retry } = useFishjamCameraEffect(
+ isBlurOn ? blur : null,
+ );
+
+ return (
+ <>
+ setIsBlurOn((value) => !value)}
+ />
+ {status === "loading" && Loading blur⦠}
+ {error && }
+ >
+ );
+}
+```
+
+While the effect loads, the hook publishes the plain camera. `useBackgroundImage` is the matching hook for image backgrounds.
+
+:::warning[One camera middleware at a time]
+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().cameraStream` shows the effect too, because it renders the published track.
+
+## Related guides
+
+- [Background blur tutorial](../../../tutorials/background-blur.mdx): build a blur toggle step by step
+- [Render WebGPU effects into the camera](./webgpu-effects.mdx): draw your own shaders instead of a ready-made effect
+- [Process camera frames in a worklet](./frame-processing.mdx): read camera frames, for example for on-device ML
+- [How camera effects work](../../../explanation/camera-effects.mdx)
+- API reference: [`createCameraEffectMiddleware`](../../../api/video-effects/fishjam-react-native/functions/createCameraEffectMiddleware.md), [`useFishjamCameraEffect`](../../../api/video-effects/fishjam-react-native/functions/useFishjamCameraEffect.md), [`typeGpuPersonSegmentation`](../../../api/video-effects/segmentation/typegpu/functions/typeGpuPersonSegmentation.md), [Video Effects package](../../../api/video-effects/index.md)
diff --git a/docs/how-to/client/camera-effects/frame-processing.mdx b/docs/how-to/client/camera-effects/frame-processing.mdx
new file mode 100644
index 00000000..2b014cbf
--- /dev/null
+++ b/docs/how-to/client/camera-effects/frame-processing.mdx
@@ -0,0 +1,128 @@
+---
+title: "Process camera frames in a worklet"
+sidebar_position: 3
+sidebar_label: "Frame processing π±"
+description: Run a worklet on every frame of the Fishjam camera track, for example to feed on-device ML, without changing the video you publish.
+---
+
+# Process camera frames in a worklet Mobile
+
+:::note
+This guide is exclusively for **Mobile** (React Native) applications.
+:::
+
+`@fishjam-cloud/react-native-worklets` runs a worklet on every frame the Fishjam camera captures. The worklet gets the frame's native buffer, so you can hand it to on-device inference or other native frame-processing code, while the camera keeps publishing as usual.
+
+Use this guide when you want to **read** frames. To **change** the published video, use [background effects](./background-effects.mdx) or [your own WebGPU effects](./webgpu-effects.mdx), which are built on the same mechanism.
+
+## Prerequisites
+
+- `@fishjam-cloud/react-native-client` **0.30.2** or newer, with your app wrapped in `FishjamProvider` (see [Installation](../installation.mdx))
+- React Native 0.86 with the New Architecture enabled
+- Android 8.0 (API 26) or newer on Android devices
+
+```bash npm2yarn
+npm install @fishjam-cloud/react-native-worklets react-native-worklets
+```
+
+The minor version of `@fishjam-cloud/react-native-worklets` follows `react-native-worklets`: `0.12.x` works with `react-native-worklets` `0.12.x`. Add the worklets Babel plugin as the last entry in your Babel plugins, then rebuild the native app:
+
+```js title='babel.config.js'
+module.exports = {
+ presets: ["babel-preset-expo"],
+ plugins: ["react-native-worklets/plugin"],
+};
+```
+
+## Attach a frame callback
+
+A frame callback attaches to a camera track through its frame processor: `getCameraFrameProcessor` from `@fishjam-cloud/react-native-webrtc` returns the processor, and `attachCameraFrameCallback` starts calling your worklet.
+
+The easiest place to do this is a [camera track middleware](./stream-middleware.mdx) that returns the camera track unchanged. The middleware receives the raw camera track, and Fishjam runs it again whenever the camera restarts or you switch cameras, so the callback always follows the current camera:
+
+```ts title='frameRateMonitor.ts'
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+import { getCameraFrameProcessor } from "@fishjam-cloud/react-native-webrtc";
+import { attachCameraFrameCallback } from "@fishjam-cloud/react-native-worklets";
+import { scheduleOnRN } from "react-native-worklets";
+
+function reportFrameRate(framesPerSecond: number) {
+ console.log(`Camera: ${framesPerSecond} fps`);
+}
+
+export const frameRateMonitor: TrackMiddleware = async (track) => {
+ const processor = await getCameraFrameProcessor(track);
+
+ const stats = { windowStartNs: 0, frames: 0 };
+ const subscription = await attachCameraFrameCallback(processor, (frame) => {
+ "worklet";
+ stats.frames += 1;
+ const elapsedNs = frame.timestampNanoseconds - stats.windowStartNs;
+ if (elapsedNs >= 1_000_000_000) {
+ scheduleOnRN(
+ reportFrameRate,
+ Math.round((stats.frames * 1e9) / elapsedNs),
+ );
+ stats.windowStartNs = frame.timestampNanoseconds;
+ stats.frames = 0;
+ }
+ });
+
+ // Publish the camera unchanged; stop the callback when the middleware is removed.
+ return { track, onClear: () => subscription.remove() };
+};
+```
+
+Switch it on with `setCameraTrackMiddleware`:
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const frameRateMonitor: NonNullable;
+// ---cut---
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+const { setCameraTrackMiddleware } = useCamera();
+
+await setCameraTrackMiddleware(frameRateMonitor);
+```
+
+The callback runs on a dedicated camera frame thread, never on the JS thread. Use `scheduleOnRN` from `react-native-worklets` to send results back to the JS thread, as in the example.
+
+## What a frame carries
+
+| Field | Description |
+| ---------------------- | ---------------------------------------------------------------------------------------------- |
+| `nativeBuffer` | `CVPixelBufferRef` on iOS, `AHardwareBuffer*` on Android, as a `bigint` pointer |
+| `width`, `height` | Size of the buffer, in pixels, before rotation |
+| `rotationDegrees` | Clockwise rotation (`0`, `90`, `180` or `270`) that brings the frame upright |
+| `isFrontCamera` | Whether the frame comes from the front camera |
+| `timestampNanoseconds` | Presentation timestamp of the frame |
+| `pixelFormat` | `"nv12"` or `"bgra8"` on iOS, depending on the camera; `"rgba8"` on Android |
+| `release()` | Hands the buffer back to the camera before the callback returns. `isReleased` tells if it was. |
+
+On Android, the camera tap converts each camera image to RGBA before your callback runs, so the buffer is always `rgba8`.
+
+:::danger[Frame lifetime]
+`nativeBuffer` is valid only until your callback returns or you call `release()`. Don't store the pointer for later use; copy what you need inside the callback.
+:::
+
+## How frames are delivered
+
+- **Frames are dropped, never queued.** While your callback holds a frame, newer frames are dropped. A slow callback lowers the rate at which you see frames, not the rate at which the camera publishes.
+- **One callback per camera track.** Remove the subscription before you attach another callback to the same track. The frame processor is also what [background effects](./background-effects.mdx) and [WebGPU effects](./webgpu-effects.mdx) use, so don't attach your own callback to a camera that already runs an effect.
+- **One worklet runtime for the app.** All frame callbacks share one camera frame runtime, created the first time you attach a callback. Its closure copies follow the usual [worklet rules](https://docs.swmansion.com/react-native-worklets/docs/fundamentals/getting-started): capture plain values and other worklets, and mutate objects captured by the worklet, not variables from the JS thread.
+
+## Troubleshooting
+
+- **`Camera frame worklets require the New Architecture.`** Turn on the New Architecture and rebuild the native app.
+- **`Camera frame worklets require Android 8.0 (API 26).`** The device is too old for hardware buffers. Skip frame processing on such devices.
+- **`@fishjam-cloud/react-native-worklets is not linked.`** Rebuild the native app after installing the package; a JS reload is not enough. On Android, if an older version was installed before, also delete `android/build/generated/autolinking` in your app.
+- **`A camera frame callback is already attached.`** Remove the previous subscription first, or check that no effect middleware is running on the camera.
+
+## Related guides
+
+- [Background effects](./background-effects.mdx): blur or replace the background
+- [Render WebGPU effects into the camera](./webgpu-effects.mdx): draw into the published frames
+- [Stream middleware](./stream-middleware.mdx): how camera track middleware works
+- [How camera effects work](../../../explanation/camera-effects.mdx)
+- API reference: [`attachCameraFrameCallback`](../../../api/react-native-worklets/functions/attachCameraFrameCallback.md), [`CameraFrame`](../../../api/react-native-worklets/interfaces/CameraFrame.md), [React Native Worklets package](../../../api/react-native-worklets/index.md)
diff --git a/docs/how-to/client/camera-effects/index.mdx b/docs/how-to/client/camera-effects/index.mdx
new file mode 100644
index 00000000..76d634b8
--- /dev/null
+++ b/docs/how-to/client/camera-effects/index.mdx
@@ -0,0 +1,60 @@
+---
+title: "Camera effects"
+description: Change or read the camera video you publish to Fishjam, from ready-made background blur to your own WebGPU shaders and frame worklets.
+---
+
+import DocCardList from "@theme/DocCardList";
+
+# Camera effects
+
+Camera effects change the camera video before it leaves the device: blur the background, replace it with an image, or draw your own shaders into every frame. You can also read camera frames without changing the video, for example to run on-device ML.
+
+All of this works on the camera Fishjam already manages. You keep using [`useCamera`](../../../api/mobile/functions/useCamera) to start, stop and switch the camera, and set the effect with `setCameraTrackMiddleware`. Other peers receive the result as your regular `cameraTrack`, so nothing changes on the receiving side.
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const backgroundBlur: NonNullable;
+// ---cut---
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+const { setCameraTrackMiddleware } = useCamera();
+
+await setCameraTrackMiddleware(backgroundBlur); // null turns the effect off
+```
+
+:::important
+
+If you want to publish something that isn't your camera, such as a game render loop or a video file, see [Custom sources](../custom-sources/index.mdx) instead.
+
+:::
+
+## Choose a guide
+
+| Guide | Platform | Use it when |
+| ---------------------------------------------- | ------------------ | --------------------------------------------------------------------------------- |
+| [Background effects](./background-effects.mdx) | π± Mobile | You want background blur or a background image, without writing shaders |
+| [WebGPU effects](./webgpu-effects.mdx) | π± Mobile Β· π Web | You want to draw your own shaders, overlays or effects into the published video |
+| [Frame processing](./frame-processing.mdx) | π± Mobile | You want to read camera frames, e.g. for on-device ML, without changing the video |
+| [Stream middleware](./stream-middleware.mdx) | π± Mobile Β· π Web | You want to understand or write the middleware that the other guides are built on |
+
+:::info[How it runs in React Native]
+
+Effects run off the JS thread. `@fishjam-cloud/react-native-worklets` hands every camera frame to a worklet on a dedicated camera thread, and `@fishjam-cloud/video-effects` renders the result with WebGPU. See [How camera effects work](../../../explanation/camera-effects.mdx) for the full picture.
+
+:::
+
+:::tip[Using VisionCamera?]
+
+If your app already captures the camera with [VisionCamera](https://react-native-vision-camera.com), you can publish that feed instead. See the [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source.mdx).
+
+:::
+
+## Guides in this section
+
+
+
+## Further reading
+
+- [Background blur tutorial](../../../tutorials/background-blur.mdx) and [WebGPU effects tutorial](../../../tutorials/webgpu-effects.mdx): build a working app step by step
+- [How camera effects work](../../../explanation/camera-effects.mdx): concepts behind the pipeline
+- API reference: [Video Effects package](../../../api/video-effects/index.md), [React Native Worklets package](../../../api/react-native-worklets/index.md)
diff --git a/docs/how-to/client/camera-effects/stream-middleware.mdx b/docs/how-to/client/camera-effects/stream-middleware.mdx
new file mode 100644
index 00000000..66cbe1e9
--- /dev/null
+++ b/docs/how-to/client/camera-effects/stream-middleware.mdx
@@ -0,0 +1,168 @@
+---
+title: "Stream middleware"
+sidebar_position: 4
+sidebar_label: "Stream middleware"
+description: Intercept and transform camera and microphone tracks before they are sent to Fishjam, enabling effects and custom encodings.
+---
+
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+
+# Stream middleware
+
+Stream middleware in Fishjam allows you to intercept and manipulate media tracks before they are sent to the Fishjam server.
+This feature is powerful for applying effects, custom encodings, or any other transformations to the media stream.
+
+The camera and microphone keep being managed by Fishjam: device selection, starting and stopping, and publishing work as usual. The middleware only decides which track goes out in place of the raw device track. Other peers receive the result as your regular `cameraTrack` or `microphoneTrack`.
+
+## Overview
+
+Define a `TrackMiddleware` function ([Web](../../../api/web/type-aliases/TrackMiddleware) Β· [React Native](../../../api/mobile/type-aliases/TrackMiddleware)) which takes a `MediaStreamTrack` and returns, directly or as a promise, an object containing the modified `MediaStreamTrack` and an optional `onClear` function, which is called when the middleware is removed or replaced, or when the device track it processes goes away.
+
+### Type definition
+
+```typescript
+type MiddlewareResult = {
+ track: MediaStreamTrack;
+ onClear?: () => void;
+};
+
+export type TrackMiddleware =
+ | ((track: MediaStreamTrack) => MiddlewareResult | Promise)
+ | null;
+```
+
+### Setting middleware
+
+You can set the middleware for your media tracks using the `setCameraTrackMiddleware` and `setMicrophoneTrackMiddleware` methods of [`useCamera`](../../../api/web/functions/useCamera) and [`useMicrophone`](../../../api/web/functions/useMicrophone). These methods accept a `TrackMiddleware` or `null` for removing previously set middleware. `currentCameraMiddleware` and `currentMicrophoneMiddleware` return the middleware that is currently set.
+
+### How middleware is applied
+
+- **It is applied to every new device track.** A middleware set before the device starts is applied when it starts. It is applied again when the device restarts or you switch to another device, so the published track is never the raw one while a middleware is set.
+- **The old track is published until the new one is ready.** An asynchronous middleware can take a moment to set up. Until it resolves, peers keep receiving the previous track. The previous middleware's `onClear` runs only after the new track has replaced it in the published stream.
+- **The latest request wins.** If you set another middleware, or `null`, while one is still setting up, the one that was replaced is released as soon as it finishes setting up and is never published.
+- **Tracks returned by the middleware are stopped for you.** When a middleware is removed, the track it returned is stopped, unless it is the input track itself. Release anything else your middleware created in `onClear`.
+
+
+
+
+## Example: Applying a video effect
+
+In React Native, a camera middleware typically runs a GPU effect on every frame of the camera track. `@fishjam-cloud/video-effects` creates such middleware for you. The example below toggles a background blur; see [Background effects](./background-effects.mdx) for the setup.
+
+```tsx
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+import React from "react";
+import { Button } from "react-native";
+import { useCamera } from "@fishjam-cloud/react-native-client";
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+// Create the middleware once, so its identity stays stable.
+const backgroundBlur = createCameraEffectMiddleware(
+ createBackgroundBlurEffect(() => ({ segmentation, radius: 24 })),
+);
+
+export function BlurToggle() {
+ // [!code highlight:2]
+ const { currentCameraMiddleware, setCameraTrackMiddleware } = useCamera();
+ const isBlurOn = currentCameraMiddleware === backgroundBlur;
+
+ return (
+ setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur)} // [!code highlight]
+ />
+ );
+}
+```
+
+To write your own middleware, see [Render WebGPU effects into the camera](./webgpu-effects.mdx), which returns a WebGPU-rendered track, and [Process camera frames in a worklet](./frame-processing.mdx), which reads frames and returns the camera track unchanged.
+
+
+
+
+## Example: Applying a blur effect
+
+The following example demonstrates how to apply a custom blur effect to the camera track using the [`useCamera`](../../../api/web/functions/useCamera) hook and middleware.
+
+```tsx
+class BlurProcessor {
+ track: MediaStreamTrack;
+
+ constructor(stream: MediaStream) {
+ this.track = this.applyBlurEffect(stream);
+ }
+
+ private applyBlurEffect(stream: MediaStream): MediaStreamTrack {
+ return stream.getVideoTracks()[0];
+ }
+
+ destroy() {}
+}
+
+// ---cut---
+import React, { useCallback, useEffect, useRef } from "react";
+import type { TrackMiddleware } from "@fishjam-cloud/react-client";
+import { useCamera } from "@fishjam-cloud/react-client";
+
+export function CameraWithBlurEffect() {
+ const videoRef = useRef(null);
+ // [!code highlight:2]
+ const { cameraStream, currentCameraMiddleware, setCameraTrackMiddleware } =
+ useCamera();
+
+ useEffect(() => {
+ if (!videoRef.current) return;
+ videoRef.current.srcObject = cameraStream ?? null;
+ }, [cameraStream]);
+
+ // Define blur middleware
+ const blurMiddleware: TrackMiddleware = useCallback(
+ (track: MediaStreamTrack) => {
+ const streamToBlur = new MediaStream([track]);
+ // BlurProcessor is just an example,
+ // process the stream however you need
+ const blurProcessor = new BlurProcessor(streamToBlur);
+
+ return {
+ track: blurProcessor.track,
+ onClear: () => blurProcessor.destroy(),
+ };
+ },
+ [],
+ );
+
+ // Check if the current middleware is blur
+ const isBlurEnabled = currentCameraMiddleware === blurMiddleware;
+
+ // Toggle blur effect
+ const toggleBlur = () => {
+ setCameraTrackMiddleware(isBlurEnabled ? null : blurMiddleware); // [!code highlight]
+ };
+
+ return (
+ <>
+
+ {isBlurEnabled ? "Disable Blur" : "Enable Blur"}
+
+
+ {cameraStream && }
+ >
+ );
+}
+```
+
+This example provides a button to toggle the blur effect on and off. The `BlurProcessor` handles the actual processing logic and is assumed to be implemented elsewhere.
+
+
+
+
+## Related guides
+
+- [Background effects](./background-effects.mdx): ready-made background blur and background image for React Native
+- [Render WebGPU effects into the camera](./webgpu-effects.mdx): return a track you render yourself
+- [Managing devices](../managing-devices.mdx): start, stop and switch the camera and microphone
+- [How camera effects work](../../../explanation/camera-effects.mdx)
diff --git a/docs/how-to/client/custom-sources/webgpu-effects.mdx b/docs/how-to/client/camera-effects/webgpu-effects.mdx
similarity index 56%
rename from docs/how-to/client/custom-sources/webgpu-effects.mdx
rename to docs/how-to/client/camera-effects/webgpu-effects.mdx
index bd002ff0..f3b05f79 100644
--- a/docs/how-to/client/custom-sources/webgpu-effects.mdx
+++ b/docs/how-to/client/camera-effects/webgpu-effects.mdx
@@ -1,24 +1,198 @@
---
-title: "Render WebGPU effects into published video"
-sidebar_position: 4
+title: "Render WebGPU effects into the camera"
+sidebar_position: 2
sidebar_label: "WebGPU effects"
-description: Draw your own shaders, overlays and effects into the video you publish to Fishjam, with WebGPU on the web and in React Native.
+description: Draw your own shaders, overlays and effects into the camera video you publish to Fishjam, with WebGPU on the web and in React Native.
---
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
-# Render WebGPU effects into published video
+# Render WebGPU effects into the camera
-This guide shows how to render each frame of the published video yourself with WebGPU. Whatever you draw is what other peers receive.
+This guide shows how to render each frame of your published camera yourself with WebGPU. Whatever you draw is what other peers receive.
The rendering code is plain WebGPU on both platforms. The integration differs:
-- On the **web**, render into a ``, capture it with [`captureStream`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream), and publish the stream with [`useCustomSource`](./web.mdx).
-- In **React Native**, use the `/webgpu` entry point of `@fishjam-cloud/react-native-vision-camera-source`. For every camera frame, your worklet receives the camera as a GPU texture and an output texture to draw into. The [`useVisionCameraWebGpuSource`](../../../api/vision-camera-source/index.md) hook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
+- In **React Native**, return a WebGPU-rendered track from a [camera track middleware](./stream-middleware.mdx). `createCameraFrameProcessorSession` from `@fishjam-cloud/video-effects` calls your worklet for every frame of the Fishjam camera, with the camera as a GPU texture and an output texture to draw into. It handles the output track, GPU synchronization with the video encoder, timestamps and frame lifetimes. Peers receive the result as your regular camera track.
+- On the **web**, render into a ``, capture it with [`captureStream`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream), and publish the stream as a [custom source](../custom-sources/web.mdx).
+
+If you only need background blur or a background image, use the [ready-made background effects](./background-effects.mdx) instead of writing shaders.
-
+
+
+## Prerequisites
+
+- The packages (`@fishjam-cloud/video-effects` 0.1.5 or newer, `react-native-webgpu` 0.10.1 or newer) and the Babel and minimum OS configuration from [Background effects](./background-effects.mdx#install). The segmentation model and the Metro and `expo-asset` setup are not needed.
+- `typegpu` in your app, if you write your shaders in TypeGPU as below
+
+```bash npm2yarn
+npm install typegpu
+```
+
+## Publish the camera through your own shaders
+
+:::tip[First time?]
+The [WebGPU effects tutorial](../../../tutorials/webgpu-effects.mdx) builds this pipeline step by step in a working app, from a passthrough render pass to a watermark overlay and a color effect.
+:::
+
+The example below publishes the camera in grayscale. It is a camera track middleware: it builds the pipeline, starts a frame processor on the raw camera track, and returns the processor's track in its place. To apply your own effect, replace the fragment stage.
+
+:::note
+The shaders are written in [TypeGPU](https://docs.swmansion.com/TypeGPU/) (TGSL): typed TypeScript functions compiled to WGSL by `unplugin-typegpu`. TypeGPU is not required; you can hand-write WGSL and prepend the bindings' `bindingDeclarations` yourself.
+:::
+
+```tsx title='grayscale.ts'
+///
+// ---cut---
+import tgpu from "typegpu";
+import * as d from "typegpu/data";
+import { dot } from "typegpu/std";
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+import {
+ createCameraFrameProcessorSession,
+ createCameraShaderBindings,
+ getCameraWebGpuDevice,
+ getOutputSurfaceFormat,
+ type CameraFrameKernel,
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+// Full-screen triangle; uv spans the visible area.
+const vertexMain = tgpu.vertexFn({
+ in: { vertexIndex: d.builtin.vertexIndex },
+ out: { position: d.builtin.position, uv: d.location(0, d.vec2f) },
+})((input) => {
+ const positions = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
+ const p = positions[input.vertexIndex];
+ return {
+ position: d.vec4f(p.x, p.y, 0, 1),
+ uv: d.vec2f((p.x + 1) * 0.5, 1 - (p.y + 1) * 0.5),
+ };
+});
+
+function createGrayscalePipeline(device: GPUDevice) {
+ const cameraBindings = createCameraShaderBindings(device, {
+ cameraPixelLayout: "rgb",
+ });
+
+ const fragmentMain = tgpu.fragmentFn({
+ in: { uv: d.location(0, d.vec2f) },
+ out: d.vec4f,
+ })((input) => {
+ const color = cameraBindings.sampleCamera(input.uv);
+ const gray = dot(color.xyz, d.vec3f(0.299, 0.587, 0.114));
+ return d.vec4f(gray, gray, gray, 1);
+ });
+
+ // TypeGPU cannot emit the camera's external-texture binding itself, so
+ // prepend cameraBindings.bindingDeclarations to the resolved WGSL.
+ const module = device.createShaderModule({
+ code:
+ cameraBindings.bindingDeclarations +
+ tgpu.resolve([vertexMain, fragmentMain]),
+ });
+ const pipeline = device.createRenderPipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [cameraBindings.bindGroupLayout],
+ }),
+ vertex: { module, entryPoint: "vertexMain" },
+ fragment: {
+ module,
+ entryPoint: "fragmentMain",
+ targets: [{ format: getOutputSurfaceFormat() }],
+ },
+ });
+ return { cameraBindings, pipeline };
+}
+
+export const grayscale: TrackMiddleware = async (track) => {
+ const device = await getCameraWebGpuDevice();
+ const { cameraBindings, pipeline } = createGrayscalePipeline(device);
+
+ const frameKernel: CameraFrameKernel = (frame, render) => {
+ "worklet";
+ render(({ commandEncoder, outputView, cameraBindGroup }) => {
+ const pass = commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: outputView, loadOp: "clear", storeOp: "store" },
+ ],
+ });
+ pass.setPipeline(pipeline);
+ pass.setBindGroup(0, cameraBindGroup!);
+ pass.draw(3);
+ pass.end();
+ });
+ };
+
+ const session = await createCameraFrameProcessorSession({
+ track,
+ device,
+ width: 720,
+ height: 1280,
+ cameraShaderBindings: cameraBindings,
+ frameKernel,
+ });
+
+ return { track: session.track, onClear: () => void session.dispose() };
+};
+```
+
+Switch it on like any camera middleware:
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const grayscale: NonNullable;
+// ---cut---
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+const { setCameraTrackMiddleware } = useCamera();
+
+await setCameraTrackMiddleware(grayscale); // null restores the plain camera
+```
+
+How the example works:
+
+- `getCameraWebGpuDevice()` returns the app-wide `GPUDevice`, requested with the features the camera import needs. Build your pipelines, textures and bind groups on this device.
+- `createCameraShaderBindings(device, { cameraPixelLayout: "rgb" })` gives your shaders `sampleCamera(uv)`, which returns upright RGB. The Fishjam camera arrives as RGB on both platforms, so the layout is always `"rgb"`.
+- TypeGPU cannot emit the camera's `texture_external` binding, so `cameraBindings.bindingDeclarations` is prepended to the resolved WGSL. The fragment stage targets `getOutputSurfaceFormat()` (`rgba8unorm` on Android, `bgra8unorm` on iOS).
+- Passing `cameraShaderBindings` to the session makes the render context carry a ready-made `cameraBindGroup`, rebuilt every frame because the camera's external texture expires with each frame.
+- `frameKernel` is a worklet. It runs on the camera frame thread for every frame, encodes one render pass, and the session submits it. The pipeline it uses is built on the JS thread and copied into the worklet.
+- The middleware returns `session.track`, and `onClear` disposes the session when the middleware is replaced, cleared, or the camera stops.
+
+## Rules inside the frame kernel
+
+The kernel receives `frame`, with `timestampNanoseconds`, `isFrontCamera`, `width`, `height` and `rotationDegrees`, and `render`. The function you pass to `render(...)` receives a `WebGpuFrameRenderContext` with the `device`, `queue`, `commandEncoder`, the live `cameraTexture` (a `GPUExternalTexture`, already upright), the output surface (`outputTexture`, `outputView`, `outputWidth`, `outputHeight`), and the upright camera size (`cameraWidth`, `cameraHeight`).
+
+:::warning[Rules your worklet must follow]
+
+- **Always draw into the provided `outputView`.** Calling `outputTexture.createView()` per frame leaks native wrappers on the frame runtime, because `GPUTextureView` has no release API.
+- **Call `render(...)` at most once per frame.** Skipping it drops the frame; nothing is published for it.
+- **Don't call `queue.submit()` yourself.** The session submits your passes and synchronizes with the video encoder.
+- **Finish GPU uploads before you create the session.** Helpers like [`queue.copyExternalImageToTexture`](https://developer.mozilla.org/en-US/docs/Web/API/GPUQueue/copyExternalImageToTexture) submit work internally. Running them from the JS thread while frames flow races the session's submissions and can crash the app, so upload textures in the middleware before `createCameraFrameProcessorSession`.
+- **Camera bind groups cannot be cached across frames**, because the external texture changes every frame. Use `cameraBindGroup`, or call `createCameraBindGroup` inside the worklet each frame.
+- **Capture only what a worklet can copy.** The kernel's closure is copied to the camera frame runtime when the session starts: GPU objects, numbers, strings and other worklets work. React state and TypeGPU root objects don't, and later changes on the JS thread are not seen by the kernel.
+
+:::
+
+## Going further
+
+- **Overlays**: a frame may contain more than one render pass. Encode additional passes into the same `outputView` (with `loadOp: "load"`) after the camera pass to draw watermarks or other content on top.
+- **Aspect ratio**: the grayscale example stretches the camera to 720Γ1280. `createCameraPassthroughPipeline` and `encodeCameraPassthrough` draw the camera cropped to fill the output, given a crop from `computeAspectFillCrop`. To sample a cropped camera from your own shaders, `createCameraTextureResolver` and `resolveCameraTexture` render it into an owned `rgba8unorm` texture first, at the cost of one extra render pass per frame.
+- **Reading frames without drawing**: to run on-device inference on camera frames without changing the published video, see [Process camera frames in a worklet](./frame-processing.mdx).
+- **Output size**: `width` and `height` set the published resolution. The camera is imported at its own resolution and your passes decide how it maps onto the output.
+
+The full toolkit is documented in the [Video Effects API reference](../../../api/video-effects/index.md).
+
+## Platform notes
+
+- The Fishjam camera arrives as RGB on both platforms. On **Android** the camera tap converts the camera texture before handing it over; on **iOS** the camera buffer is imported directly.
+- `getOutputSurfaceFormat()` returns the published surface format: `rgba8unorm` on Android, `bgra8unorm` on iOS. Use it for your fragment targets instead of hard-coding a format.
+- The published frames are not mirrored. The local self-view mirrors the front camera when you render it with `mirror={true}`.
+
+
+
+
## Publish a canvas
@@ -33,7 +207,7 @@ const { setStream } = useCustomSource("my-canvas");
setStream(canvas.captureStream(30));
```
-`setStream` comes from the same [`useCustomSource`](./web.mdx) API as any custom source, and other peers receive the feed among their [`customVideoTracks`](./index.mdx#receiving-custom-tracks).
+`setStream` comes from the same [`useCustomSource`](../custom-sources/web.mdx) API as any custom source, and other peers receive the feed among their [`customVideoTracks`](../custom-sources/index.mdx#receiving-custom-tracks).
The rest of this guide builds a worked example that renders the camera in grayscale. Any render pass works the same way: replace the shaders and keep the rest.
@@ -193,9 +367,7 @@ export function GrayscaleCameraPublisher() {
});
const sampler = device.createSampler();
const module = device.createShaderModule({
- code:
- bindingDeclarations +
- tgpu.resolve({ externals: { vertexMain, fragmentMain } }),
+ code: bindingDeclarations + tgpu.resolve([vertexMain, fragmentMain]),
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
@@ -258,7 +430,7 @@ export function GrayscaleCameraPublisher() {
The same canvas serves as the self-view, so peers see exactly what you see.
-Unlike React Native, the browser handles encoder synchronization, timestamps and frame lifetimes, so there are no surface pools or fences to manage. The camera also arrives as decoded RGB; no YUV handling is needed.
+The browser handles encoder synchronization, timestamps and frame lifetimes, so there is no frame processor session to create.
## Going further
@@ -277,216 +449,15 @@ Pipelines written against plain 2D textures can copy the video in with [`queue.c
:::
-
-
-Every camera frame reaches your `onFrame` worklet. Calling `render(...)` gives you the live camera as a GPU texture and an output texture to draw into. Whatever you draw is what peers receive.
-## Prerequisites
-
-On top of the [Vision Camera setup](./vision-camera.mdx#prerequisites):
-
-- `react-native-webgpu` β₯ 0.5.15
-- `unplugin-typegpu` in your app's Babel config (the TGSL shaders need its build-time transform)
-- iOS 17+: the camera-import path relies on Metal external-texture features not guaranteed on earlier versions. The [base integration](./vision-camera.mdx) has no such requirement, so you can keep your `ios.deploymentTarget` unchanged and gate WebGPU usage at runtime.
-
-```bash npm2yarn
-npm install react-native-webgpu typegpu unplugin-typegpu
-```
-
-```js title='babel.config.js'
-module.exports = {
- presets: ["babel-preset-expo"],
- plugins: ["unplugin-typegpu/babel", "react-native-worklets/plugin"],
-};
-```
-
-Keep `react-native-worklets/plugin` as the last plugin.
-
-## Get a camera-capable GPU device
-
-`useCameraWebGpuDevice` returns an app-wide shared `GPUDevice`, requested with the features the camera-import path needs:
-
-```tsx
-import { useCameraWebGpuDevice } from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
-// ---cut---
-const { device, error } = useCameraWebGpuDevice();
-```
-
-To use your own device instead, pass it as the `device` option of `useVisionCameraWebGpuSource`. It is validated against `getRequiredWebGpuCameraFeatures()`; a device missing any of the features surfaces a descriptive `error` instead of failing per frame.
-
-## Publish the camera through your own shaders
-
-:::tip[First time?]
-The [WebGPU effects tutorial](../../../tutorials/webgpu-effects.mdx) builds this pipeline step by step in a working app, from a passthrough render pass to a watermark overlay and a color effect.
-:::
-
-The example below publishes the camera in grayscale. To apply your own effect, replace the fragment stage. Your component only does the drawing; the hook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
-
-:::note
-The shaders are written in [TypeGPU](https://docs.swmansion.com/TypeGPU/) (TGSL): typed TypeScript functions compiled to WGSL by `unplugin-typegpu`. TypeGPU is not required; you can hand-write WGSL and prepend the bindings' `bindingDeclarations` yourself.
-:::
-
-```tsx
-import React, { useCallback, useMemo } from "react";
-import tgpu from "typegpu";
-import * as d from "typegpu/data";
-import { dot } from "typegpu/std";
-import {
- useCamera as useVisionCamera,
- useCameraPermission,
- type Frame,
-} from "react-native-vision-camera";
-import { RTCView } from "@fishjam-cloud/react-native-client";
-import {
- useVisionCameraWebGpuSource,
- useCameraWebGpuDevice,
- createCameraShaderBindings,
- getOutputSurfaceFormat,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
-
-// Full-screen triangle; uv spans the visible area.
-const vertexMain = tgpu.vertexFn({
- in: { vertexIndex: d.builtin.vertexIndex },
- out: { position: d.builtin.position, uv: d.location(0, d.vec2f) },
-})((input) => {
- const positions = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
- const p = positions[input.vertexIndex];
- return {
- position: d.vec4f(p.x, p.y, 0, 1),
- uv: d.vec2f((p.x + 1) * 0.5, 1 - (p.y + 1) * 0.5),
- };
-});
-
-export function GrayscaleCameraPublisher() {
- const { hasPermission } = useCameraPermission();
- const { device } = useCameraWebGpuDevice();
-
- const effect = useMemo(() => {
- if (device == null) return null;
-
- const cameraBindings = createCameraShaderBindings(device);
- const fragmentMain = tgpu.fragmentFn({
- in: { uv: d.location(0, d.vec2f) },
- out: d.vec4f,
- })((input) => {
- const color = cameraBindings.sampleCamera(input.uv);
- const gray = dot(color.xyz, d.vec3f(0.299, 0.587, 0.114));
- return d.vec4f(gray, gray, gray, 1);
- });
-
- // TypeGPU cannot emit the external-texture binding itself, so prepend
- // cameraBindings.bindingDeclarations to the resolved WGSL.
- const module = device.createShaderModule({
- code:
- cameraBindings.bindingDeclarations +
- tgpu.resolve({ externals: { vertexMain, fragmentMain } }),
- });
- const pipeline = device.createRenderPipeline({
- layout: device.createPipelineLayout({
- bindGroupLayouts: [cameraBindings.bindGroupLayout],
- }),
- vertex: { module, entryPoint: "vertexMain" },
- fragment: {
- module,
- entryPoint: "fragmentMain",
- targets: [{ format: getOutputSurfaceFormat() }],
- },
- });
- return { cameraBindings, pipeline };
- }, [device]);
-
- const onFrame = useCallback(
- (frame: Frame, render: WebGpuFrameRenderFunction) => {
- "worklet";
- if (effect == null) return; // drop frames until the pipeline is ready
- render(({ commandEncoder, outputView, cameraBindGroup }) => {
- const pass = commandEncoder.beginRenderPass({
- colorAttachments: [
- { view: outputView, loadOp: "clear", storeOp: "store" },
- ],
- });
- pass.setPipeline(effect.pipeline);
- pass.setBindGroup(0, cameraBindGroup!);
- pass.draw(3);
- pass.end();
- });
- },
- [effect],
- );
-
- const { frameOutput, stream } = useVisionCameraWebGpuSource("my-camera", {
- width: 720,
- height: 1280,
- cameraShaderBindings: effect?.cameraBindings,
- onFrame,
- });
-
- useVisionCamera({
- device: "front",
- isActive: hasPermission,
- outputs: [frameOutput],
- });
-
- if (!stream) return null;
- return (
-
- );
-}
-```
-
-How the example works:
-
-- `createCameraShaderBindings(device)` gives your shaders `sampleCamera(uv)`, which returns upright RGB on both platforms and handles the YUV decode for you.
-- Everything created from the `device` (bindings, shaders, pipeline) lives in one `useMemo` keyed by the device. TypeGPU cannot emit the camera's `texture_external` binding, so `cameraBindings.bindingDeclarations` is prepended to the resolved WGSL, and the fragment targets `getOutputSurfaceFormat()` (`rgba8unorm` on Android, `bgra8unorm` on iOS).
-- Passing `cameraShaderBindings` to the hook makes the render context carry a ready-made `cameraBindGroup`, rebuilt each frame because the camera's external texture expires with every frame.
-- The worklet encodes one render pass; the hook submits it and synchronizes with the video encoder. `frameOutput` plugs into VisionCamera's `useCamera`; `stream` is the self-view.
-
-Other peers receive the feed among their [`customVideoTracks`](./index.mdx#receiving-custom-tracks).
-
-## Rules inside `onFrame`
-
-The callback you pass to `render(...)` receives a `WebGpuFrameRenderContext` with the `device`, `queue`, `commandEncoder`, the live `cameraTexture` (a `GPUExternalTexture`), the output surface (`outputTexture`, `outputView`, `outputWidth`, `outputHeight`), and camera metadata (`cameraWidth`, `cameraHeight`, `cameraIsMirrored`).
-
-:::warning[Rules your worklet must follow]
-
-- **Always draw into the provided `outputView`.** Calling `outputTexture.createView()` per frame leaks native wrappers on the frame runtime, because `GPUTextureView` has no release API.
-- **Call `render(...)` at most once per frame.** Skipping it drops the frame; nothing is published for it.
-- **Don't call `queue.submit()` yourself.** The hook submits your passes and synchronizes with the video encoder.
-- **Finish GPU uploads before frames flow.** Helpers like [`queue.copyExternalImageToTexture`](https://developer.mozilla.org/en-US/docs/Web/API/GPUQueue/copyExternalImageToTexture) submit work internally. Running them from the JS thread while the source is active races the hook's submissions and can crash the app, so upload textures before activating the camera.
-- **Camera bind groups cannot be cached across frames**, because the external texture changes every frame. The hook rebuilds `cameraBindGroup` for you; if you build your own, call `createCameraBindGroup` inside the worklet each frame.
-- **Keep `onFrame`'s identity stable** (`useCallback` or module scope).
-
-:::
-
-After `render(...)` returns you may keep using the `frame` (for example, to run inference on it), but only until your callback returns; the hook releases it afterwards.
-
-## Going further
-
-- **Overlays**: a frame may contain more than one render pass. Encode additional passes into the same `outputView` (with `loadOp: "load"`) after the camera pass to draw watermarks or other content on top.
-- **Cropping helpers**: the grayscale example stretches the camera to the output. `computeAspectFillCrop` and `computeSquareCrop` compute the crop that fills your output aspect ratio (like `objectFit: "cover"`); `packFrameCropParams` packs a crop for your own uniform buffers.
-- **Pipelines that cannot sample `texture_external`** can resolve the camera into an owned `rgba8unorm` texture with `createCameraTextureResolver` and `resolveCameraTexture`, at the cost of one extra render pass per frame.
-
-The full toolkit is documented in the [Custom Video Source API reference](../../../api/custom-video-source/index.md).
-
-## Platform notes
-
-- `sampleCamera(uv)` returns upright RGB on both platforms. On **Android** it performs the BT.709 limited-range YUVβRGB decode in-shader; on **iOS** the camera already arrives as RGB.
-- `getOutputSurfaceFormat()` returns the published surface format: `rgba8unorm` on Android, `bgra8unorm` on iOS. Use it for your fragment targets instead of hard-coding a format.
-- The context's `cameraIsMirrored` tells you whether the camera feed is mirrored (typically the front camera).
-
-
## Related guides
- [WebGPU effects tutorial](../../../tutorials/webgpu-effects.mdx): build this pipeline step by step in a working app
-- [Custom sources on the web](./web.mdx): other ways to obtain a `MediaStream` on the web
-- [Vision Camera](./vision-camera.mdx): publish the camera without custom rendering
-- [Low-level frame API](./low-level-frame-api.mdx): the pooled-surface layer the React Native hook builds on
-- [How custom sources work](../../../explanation/custom-sources.mdx)
-- API reference: [Vision Camera Source package](../../../api/vision-camera-source/index.md), [Custom Video Source package](../../../api/custom-video-source/index.md)
+- [Background effects](./background-effects.mdx): ready-made background blur and background image
+- [Process camera frames in a worklet](./frame-processing.mdx): read camera frames without changing the video
+- [Custom sources on the web](../custom-sources/web.mdx): other ways to obtain a `MediaStream` on the web
+- [WebGPU effects with VisionCamera](../../../integrations/vision-camera/webgpu-effects.mdx): the same toolkit on a VisionCamera feed
+- [How camera effects work](../../../explanation/camera-effects.mdx)
+- API reference: [`createCameraFrameProcessorSession`](../../../api/video-effects/fishjam-react-native/functions/createCameraFrameProcessorSession.md), [`createCameraShaderBindings`](../../../api/video-effects/fishjam-react-native/functions/createCameraShaderBindings.md), [Video Effects package](../../../api/video-effects/index.md)
diff --git a/docs/how-to/client/custom-sources/index.mdx b/docs/how-to/client/custom-sources/index.mdx
index bf747dce..62edac2a 100644
--- a/docs/how-to/client/custom-sources/index.mdx
+++ b/docs/how-to/client/custom-sources/index.mdx
@@ -1,6 +1,6 @@
---
title: "Custom sources"
-description: Publish any video or audio content to a Fishjam room, from canvas and WebGPU on the web to Vision Camera and native frame pipelines in React Native.
+description: Publish any video or audio content to a Fishjam room, from canvas and WebGPU on the web to native frame pipelines in React Native.
---
import Tabs from "@theme/Tabs";
@@ -13,7 +13,7 @@ Custom sources allow you to publish media that doesn't come straight from the ca
:::important
-If you only wish to send plain camera, microphone or screen share output through Fishjam, then you most likely should refer to [Streaming media](../start-streaming.mdx), [Managing devices](../managing-devices.mdx) and [Screen sharing](../screensharing.mdx) instead of this section.
+If you only wish to send plain camera, microphone or screen share output through Fishjam, then you most likely should refer to [Streaming media](../start-streaming.mdx), [Managing devices](../managing-devices.mdx) and [Screen sharing](../screensharing.mdx) instead of this section. To blur the background or draw effects into your camera, see [Camera effects](../camera-effects/index.mdx).
:::
@@ -21,17 +21,22 @@ Every custom source follows the same contract: you register a `MediaStream` unde
## Choose a guide
-| Guide | Platform | Use it when |
-| ------------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------- |
-| [Web](./web.mdx) | π Web | You have a `MediaStream` from a canvas, a media element, Web Audio, or a library like Smelter |
-| [React Native](./react-native.mdx) | π± Mobile | You have a React Native `MediaStream` to publish, or want to publish app-generated audio |
-| [Vision Camera](./vision-camera.mdx) | π± Mobile | You want to publish a VisionCamera feed, optionally running frame processors on it |
-| [WebGPU effects](./webgpu-effects.mdx) | π Web Β· π± Mobile | You want to draw your own shaders, overlays or effects into the published video |
-| [Low-level frame API](./low-level-frame-api.mdx) | π± Mobile | You produce video frames yourself, e.g. from a native pipeline or your own renderer |
+| Guide | Platform | Use it when |
+| ------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------------- |
+| [Web](./web.mdx) | π Web | You have a `MediaStream` from a canvas, a media element, Web Audio, or a library like Smelter |
+| [React Native](./react-native.mdx) | π± Mobile | You have a React Native `MediaStream` to publish, or want to publish app-generated audio |
+| [Low-level frame API](./low-level-frame-api.mdx) | π± Mobile | You produce video frames yourself, e.g. from a native pipeline or your own renderer |
+| [WebGPU effects](../camera-effects/webgpu-effects.mdx) | π Web | You render your own shaders into a canvas and want to publish it |
:::info[Platform support]
-On the web you can publish both video and audio from any `MediaStream`. In React Native, custom video comes from the video guides in this section, and custom audio from the [`useCustomAudioSource`](./react-native.mdx#publish-custom-audio) hook, which publishes PCM samples your app pushes.
+On the web you can publish both video and audio from any `MediaStream`. In React Native, custom video comes from the [low-level frame API](./low-level-frame-api.mdx) or the [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source.mdx), and custom audio from the [`useCustomAudioSource`](./react-native.mdx#publish-custom-audio) hook, which publishes PCM samples your app pushes.
+
+:::
+
+:::tip[Publishing from VisionCamera?]
+
+If your app captures the camera with [VisionCamera](https://react-native-vision-camera.com), publish that feed as a custom source with the [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source.mdx).
:::
@@ -112,4 +117,4 @@ Incoming `customAudioTracks` are played back automatically; you don't render any
## Further reading
- [How custom sources work](../../../explanation/custom-sources.mdx): concepts behind the pipeline
-- API reference: [`useCustomSource` (Web)](../../../api/web/functions/useCustomSource.md), [`useCustomSource` (React Native)](../../../api/mobile/functions/useCustomSource.md), [`useCustomAudioSource` (React Native)](../../../api/mobile/functions/useCustomAudioSource.md), [Vision Camera Source package](../../../api/vision-camera-source/index.md), [Custom Video Source package](../../../api/custom-video-source/index.md)
+- API reference: [`useCustomSource` (Web)](../../../api/web/functions/useCustomSource.md), [`useCustomSource` (React Native)](../../../api/mobile/functions/useCustomSource.md), [`useCustomAudioSource` (React Native)](../../../api/mobile/functions/useCustomAudioSource.md), [Custom Video Source package](../../../api/custom-video-source/index.md)
diff --git a/docs/how-to/client/custom-sources/low-level-frame-api.mdx b/docs/how-to/client/custom-sources/low-level-frame-api.mdx
index c1bc079f..1c611066 100644
--- a/docs/how-to/client/custom-sources/low-level-frame-api.mdx
+++ b/docs/how-to/client/custom-sources/low-level-frame-api.mdx
@@ -11,7 +11,7 @@ description: Publish video frames from any source, such as a native ML pipeline
This guide is exclusively for **Mobile** (React Native) applications.
:::
-`@fishjam-cloud/react-native-custom-video-source` is the generic layer under the [Vision Camera integration](./vision-camera). Use it directly when your frames come from another source, such as a native ML pipeline or your own renderer. It is built on the raw track primitives of `@fishjam-cloud/react-native-webrtc`, covered [at the end of this guide](#raw-track-primitives).
+`@fishjam-cloud/react-native-custom-video-source` is the generic layer under the [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source). Use it directly when your frames come from another source, such as a native ML pipeline or your own renderer. It is built on the raw track primitives of `@fishjam-cloud/react-native-webrtc`, covered [at the end of this guide](#raw-track-primitives).
## Pick a mode
@@ -147,7 +147,7 @@ If your renderer works asynchronously, pass a `fence` so the encoder waits for y
- **iOS**: `handle` is an `MTLSharedEvent` pointer and `signaledValue` the value your GPU work signals.
- **Android**: `handle` is a sync file descriptor; pass `0n` as `signaledValue`.
-If you render with WebGPU, prefer [`useVisionCameraWebGpuSource`](./webgpu-effects) or the `/webgpu` toolkit of this package; they handle fencing, surface import, and frame lifetimes for you.
+If you render with WebGPU, prefer the `/webgpu` toolkit of this package, or [`createCameraFrameProcessorSession`](../camera-effects/webgpu-effects.mdx) when you draw on the camera; they handle fencing, surface import, and frame lifetimes for you.
## Lifecycle and errors
@@ -189,11 +189,11 @@ When using the primitives directly, you must follow the rules the hooks otherwis
## WebGPU render targets
-The `/webgpu` entry point of this package is a camera-rendering toolkit for the pooled mode. It provides a shared camera-capable `GPUDevice`, camera sampling from your shaders, a passthrough pipeline, and crop helpers. It is covered in [WebGPU effects](./webgpu-effects).
+The `/webgpu` entry point of this package is a camera-rendering toolkit for the pooled mode. It provides a shared camera-capable `GPUDevice`, camera sampling from your shaders, a passthrough pipeline, and crop helpers. It is covered in [WebGPU effects with VisionCamera](../../../integrations/vision-camera/webgpu-effects).
## Related guides
- [Custom sources in React Native](./react-native): publishing the stream
-- [Vision Camera](./vision-camera): the ready-made camera integration built on this layer
+- [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source): the ready-made VisionCamera integration built on this layer
- [How custom sources work](../../../explanation/custom-sources)
- API reference: [Custom Video Source package](../../../api/custom-video-source/index)
diff --git a/docs/how-to/client/custom-sources/react-native.mdx b/docs/how-to/client/custom-sources/react-native.mdx
index ca34a0c7..49aba046 100644
--- a/docs/how-to/client/custom-sources/react-native.mdx
+++ b/docs/how-to/client/custom-sources/react-native.mdx
@@ -13,7 +13,7 @@ This guide is exclusively for **Mobile** (React Native) applications.
`@fishjam-cloud/react-native-client` provides two hooks for publishing custom sources. Your app must be wrapped in [`FishjamProvider`](../../../api/mobile/functions/FishjamProvider).
-- [`useCustomSource`](../../../api/mobile/functions/useCustomSource) publishes a `MediaStream`. It is the same hook that is available [on the web](./web), typed for the React Native [`MediaStream`](../../../api/mobile/classes/MediaStream). The stream typically comes from the [Vision Camera integration](./vision-camera) or the [low-level frame API](./low-level-frame-api), whose hooks create it for you, but any `MediaStream` you hold works.
+- [`useCustomSource`](../../../api/mobile/functions/useCustomSource) publishes a `MediaStream`. It is the same hook that is available [on the web](./web), typed for the React Native [`MediaStream`](../../../api/mobile/classes/MediaStream). The stream typically comes from the [low-level frame API](./low-level-frame-api) or the [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source), whose hooks create it for you, but any `MediaStream` you hold works.
- [`useCustomAudioSource`](../../../api/mobile/functions/useCustomAudioSource) publishes audio your app generates itself: you push PCM samples and the hook manages the track and its stream. See [Publish custom audio](#publish-custom-audio).
## Publish a stream
@@ -142,7 +142,7 @@ Custom tracks arrive in the `customVideoTracks` and `customAudioTracks` arrays o
## Related guides
-- [Vision Camera](./vision-camera): publish a camera feed with frame processors
-- [WebGPU effects](./webgpu-effects): draw your own content into the published video
+- [Camera effects](../camera-effects/index.mdx): blur the background or draw your own content into the published camera
+- [VisionCamera integration](../../../integrations/vision-camera/vision-camera-source): publish a VisionCamera feed with frame processors
- [Low-level frame API](./low-level-frame-api): publish frames from your own pipeline
- [How custom sources work](../../../explanation/custom-sources)
diff --git a/docs/how-to/client/custom-sources/web.mdx b/docs/how-to/client/custom-sources/web.mdx
index 98f3025b..75e04409 100644
--- a/docs/how-to/client/custom-sources/web.mdx
+++ b/docs/how-to/client/custom-sources/web.mdx
@@ -17,7 +17,7 @@ This guide is exclusively for **Web** (React) applications. For React Native, se
This guide demonstrates how to stream non-standard video or audio to other peers in your web app.
The utilities in this section allow you to integrate Fishjam with powerful browser APIs such as [WebGL](https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API) and [WebGPU](https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API),
or higher level libraries, which leverage these APIs, such as [Three.js](https://threejs.org/), [Smelter](https://smelter.dev) or [PixiJS](https://pixijs.com/).
-For a complete WebGPU render-and-publish pipeline, see [WebGPU effects](./webgpu-effects.mdx).
+For a complete WebGPU render-and-publish pipeline, see [WebGPU effects](../camera-effects/webgpu-effects.mdx).
## Creating a custom source - [`useCustomSource()`](../../../api/web/functions/useCustomSource)
diff --git a/docs/how-to/client/stream-middleware.mdx b/docs/how-to/client/stream-middleware.mdx
deleted file mode 100644
index 714ab90c..00000000
--- a/docs/how-to/client/stream-middleware.mdx
+++ /dev/null
@@ -1,105 +0,0 @@
----
-title: "Stream middleware"
-sidebar_position: 7
-sidebar_label: "Stream middleware π"
-description: Intercept and transform media tracks before sending them to Fishjam, enabling effects and custom encodings.
----
-
-# Stream middleware Web
-
-:::note
-This guide is exclusively for **Web** (React) applications.
-:::
-
-Stream middleware in Fishjam allows you to intercept and manipulate media tracks before they are sent to the Fishjam server.
-This feature is powerful for applying effects, custom encodings, or any other transformations to the media stream.
-
-## Overview
-
-Define a [`TrackMiddleware`](../../api/web/type-aliases/TrackMiddleware) function which takes a `MediaStreamTrack` and returns an object containing the modified `MediaStreamTrack`
-and an optional `onClear` function, which is called when the middleware needs to be removed or reapplied when a device changes.
-
-### Type definition
-
-```typescript
-export type TrackMiddleware = (
- track: MediaStreamTrack,
-) => { track: MediaStreamTrack; onClear?: () => void } | null;
-```
-
-### Setting middleware
-
-You can set the middleware for your media tracks using [`setTrackMiddleware`](../../api/web/functions/useCamera) method. This method accepts a `TrackMiddleware` or `null` for removing previously set middleware.
-
-### Example: Applying a blur effect
-
-The following example demonstrates how to apply a custom blur effect to the camera track using the [`useCamera`](../../api/web/functions/useCamera) hook and middleware.
-
-```tsx
-class BlurProcessor {
- track: MediaStreamTrack;
-
- constructor(stream: MediaStream) {
- this.track = this.applyBlurEffect(stream);
- }
-
- private applyBlurEffect(stream: MediaStream): MediaStreamTrack {
- return stream.getVideoTracks()[0];
- }
-
- destroy() {}
-}
-
-// ---cut---
-import React, { useCallback, useEffect, useRef } from "react";
-import type { TrackMiddleware } from "@fishjam-cloud/react-client";
-import { useCamera } from "@fishjam-cloud/react-client";
-
-export function CameraWithBlurEffect() {
- const videoRef = useRef(null);
- // [!code highlight:2]
- const { cameraStream, currentCameraMiddleware, setCameraTrackMiddleware } =
- useCamera();
-
- useEffect(() => {
- if (!videoRef.current) return;
- videoRef.current.srcObject = cameraStream ?? null;
- }, [cameraStream]);
-
- // Define blur middleware
- const blurMiddleware: TrackMiddleware = useCallback(
- (track: MediaStreamTrack) => {
- const streamToBlur = new MediaStream([track]);
- // BlurProcessor is just an example,
- // process the stream however you need
- const blurProcessor = new BlurProcessor(streamToBlur);
-
- return {
- track: blurProcessor.track,
- onClear: () => blurProcessor.destroy(),
- };
- },
- [],
- );
-
- // Check if the current middleware is blur
- const isBlurEnabled = currentCameraMiddleware === blurMiddleware;
-
- // Toggle blur effect
- const toggleBlur = () => {
- setCameraTrackMiddleware(isBlurEnabled ? null : blurMiddleware); // [!code highlight]
- };
-
- return (
- <>
-
- {isBlurEnabled ? "Disable Blur" : "Enable Blur"}
-
-
- {cameraStream && }
- >
- );
-}
-```
-
-This example provides a button to toggle the blur effect on and off. The `BlurProcessor` handles the actual processing logic and is assumed to be implemented elsewhere.
diff --git a/docs/integrations/vision-camera/_category_.json b/docs/integrations/vision-camera/_category_.json
new file mode 100644
index 00000000..5c41a825
--- /dev/null
+++ b/docs/integrations/vision-camera/_category_.json
@@ -0,0 +1,9 @@
+{
+ "label": "Vision Camera",
+ "position": 3,
+ "link": {
+ "type": "generated-index",
+ "title": "Vision Camera",
+ "description": "Publish a react-native-vision-camera feed to Fishjam, run frame processors on it, and draw your own WebGPU effects into it."
+ }
+}
diff --git a/docs/tutorials/vision-camera.mdx b/docs/integrations/vision-camera/stream-vision-camera.mdx
similarity index 86%
rename from docs/tutorials/vision-camera.mdx
rename to docs/integrations/vision-camera/stream-vision-camera.mdx
index 9ed38979..3d3344ab 100644
--- a/docs/tutorials/vision-camera.mdx
+++ b/docs/integrations/vision-camera/stream-vision-camera.mdx
@@ -1,6 +1,6 @@
---
type: tutorial
-sidebar_position: 5
+sidebar_position: 1
sidebar_label: "Stream Vision Camera to Fishjam π±"
description: Step-by-step guide to publishing a react-native-vision-camera feed to a Fishjam room as a custom video source.
---
@@ -14,6 +14,10 @@ import Tabs from "@theme/Tabs";
This tutorial is exclusively for **Mobile** (React Native) applications.
:::
+:::tip[Don't need VisionCamera?]
+Fishjam manages the camera itself with [`useCamera`](../../how-to/client/start-streaming.mdx), and can blur the background or run your own shaders on it. If you don't need VisionCamera for something else, follow the [background blur tutorial](../../tutorials/background-blur.mdx) or the [WebGPU effects tutorial](../../tutorials/webgpu-effects.mdx) instead.
+:::
+
This tutorial walks you through publishing a [VisionCamera](https://react-native-vision-camera.com) feed to a Fishjam room, step by step.
By the end, your camera frames will reach other participants as a custom video track, the same path you'd later use to run frame processors or draw your own effects into the published video.
@@ -29,8 +33,8 @@ A React Native app that joins a room and publishes the device camera through Vis
## Prerequisites
-- The [React Native Quick Start](./react-native-quick-start) completed, or an existing app wrapped in `FishjamProvider`
-- A physical device, or the iOS Simulator with a virtual camera from [SimCam](../how-to/client/ios-simulator-camera.mdx)
+- The [React Native Quick Start](../../tutorials/react-native-quick-start) completed, or an existing app wrapped in `FishjamProvider`
+- A physical device, or the iOS Simulator with a virtual camera from [SimCam](../../how-to/client/ios-simulator-camera.mdx)
- The New Architecture enabled; custom video tracks require it
## Step 1: Install and configure
@@ -111,7 +115,7 @@ cd ios && pod install
## Step 2: Join a room
-As in the [quick start](./react-native-quick-start#step-2-join-a-room-and-start-streaming), join a room with a peer token. This time there is no need to initialize the camera through the Fishjam SDK, since VisionCamera will manage the camera:
+As in the [quick start](../../tutorials/react-native-quick-start#step-2-join-a-room-and-start-streaming), join a room with a peer token. This time there is no need to initialize the camera through the Fishjam SDK, since VisionCamera will manage the camera:
```tsx
import React from "react";
@@ -215,7 +219,7 @@ export function RemoteStreams() {
}
```
-To verify end to end, join the same room from a second device or another client built in the [quick start](./react-native-quick-start), and watch the published feed arrive.
+To verify end to end, join the same room from a second device or another client built in the [quick start](../../tutorials/react-native-quick-start), and watch the published feed arrive.
## Complete example
@@ -349,7 +353,7 @@ export default function App() {
## Next steps
-- Run inference on the published frames; see [Publish a Vision Camera feed](../how-to/client/custom-sources/vision-camera#run-frame-processors-on-published-frames)
-- Continue this tutorial with [Draw WebGPU effects into your published camera](./webgpu-effects): a watermark overlay, then your own shaders (see the [WebGPU effects how-to](../how-to/client/custom-sources/webgpu-effects) for reference)
-- Explore the [custom sources overview](../how-to/client/custom-sources/index.mdx) and the [low-level frame API](../how-to/client/custom-sources/low-level-frame-api)
-- Learn [how custom sources work](../explanation/custom-sources) under the hood
+- Run inference on the published frames; see [Publish a Vision Camera feed](./vision-camera-source#run-frame-processors-on-published-frames)
+- Draw your own shaders into the VisionCamera feed; see [WebGPU effects with VisionCamera](./webgpu-effects.mdx)
+- Explore the [custom sources overview](../../how-to/client/custom-sources/index.mdx) and the [low-level frame API](../../how-to/client/custom-sources/low-level-frame-api)
+- Learn [how custom sources work](../../explanation/custom-sources) under the hood
diff --git a/docs/how-to/client/custom-sources/vision-camera.mdx b/docs/integrations/vision-camera/vision-camera-source.mdx
similarity index 82%
rename from docs/how-to/client/custom-sources/vision-camera.mdx
rename to docs/integrations/vision-camera/vision-camera-source.mdx
index 6db0eeb2..4d605ce8 100644
--- a/docs/how-to/client/custom-sources/vision-camera.mdx
+++ b/docs/integrations/vision-camera/vision-camera-source.mdx
@@ -1,7 +1,7 @@
---
title: "Publish a Vision Camera feed"
-sidebar_position: 3
-sidebar_label: "Vision Camera π±"
+sidebar_position: 2
+sidebar_label: "Publish a Vision Camera feed π±"
description: Publish a react-native-vision-camera feed to Fishjam, optionally running frame-processor plugins on the published frames.
---
@@ -11,15 +11,15 @@ description: Publish a react-native-vision-camera feed to Fishjam, optionally ru
This guide is exclusively for **Mobile** (React Native) applications.
:::
-`@fishjam-cloud/react-native-vision-camera-source` publishes a [VisionCamera](https://react-native-vision-camera.com) feed to Fishjam. Its hooks work like the other Fishjam source hooks ([`useCamera`](../../../api/mobile/functions/useCamera), [`useScreenShare`](../../../api/mobile/functions/useScreenShare), [`useCustomSource`](../../../api/mobile/functions/useCustomSource)): they create the underlying track, publish it, and clean up on unmount. Each camera frame is handed to Fishjam without copying its pixels.
+`@fishjam-cloud/react-native-vision-camera-source` publishes a [VisionCamera](https://react-native-vision-camera.com) feed to Fishjam. Its hooks work like the other Fishjam source hooks ([`useCamera`](../../api/mobile/functions/useCamera), [`useScreenShare`](../../api/mobile/functions/useScreenShare), [`useCustomSource`](../../api/mobile/functions/useCustomSource)): they create the underlying track, publish it, and clean up on unmount. Each camera frame is handed to Fishjam without copying its pixels.
-Use this package when you need VisionCamera capabilities alongside a Fishjam call, such as frame-processor plugins for on-device ML, precise device control, or [your own WebGPU rendering](./webgpu-effects) drawn into the published video. If you only want to stream the camera, use [`useCamera`](../start-streaming) instead.
+Use this package when you need VisionCamera capabilities alongside a Fishjam call, such as frame-processor plugins for on-device ML, precise device control, or [your own WebGPU rendering](./webgpu-effects) drawn into the published video. If you only want to stream the camera, use [`useCamera`](../../how-to/client/start-streaming.mdx) instead. To blur the background, draw your own shaders into the camera, or read camera frames in a worklet, you don't need VisionCamera either: see [Camera effects](../../how-to/client/camera-effects/index.mdx).
## Prerequisites
- `react-native-vision-camera` **v5** and `react-native-vision-camera-worklets`
- [`react-native-worklets`](https://docs.swmansion.com/react-native-worklets/) with its Babel plugin configured (required by VisionCamera's frame outputs)
-- `@fishjam-cloud/react-native-client` with your app wrapped in `FishjamProvider` (see [Installation](../installation))
+- `@fishjam-cloud/react-native-client` with your app wrapped in `FishjamProvider` (see [Installation](../../how-to/client/installation.mdx))
- The New Architecture; custom video tracks require it
## Install
@@ -76,11 +76,11 @@ export function CameraPublisher() {
Frame rotation and timestamps are handled for you: the hook normalizes VisionCamera's per-platform timestamp units onto one monotonic timeline and applies the frame's orientation automatically.
-Other peers receive the feed among their [`customVideoTracks`](./index.mdx#receiving-custom-tracks).
+Other peers receive the feed among their [`customVideoTracks`](../../how-to/client/custom-sources/index.mdx#receiving-custom-tracks).
:::warning[On-screen drawing is not published]
-The published track carries the camera frames exactly as captured. Anything you draw on top of the preview (React Native views, a [Skia](https://shopify.github.io/react-native-skia/) canvas, or any other overlay) appears only in your local UI and is **not** sent to Fishjam. To render content into the published video itself, use [WebGPU effects](./webgpu-effects.mdx) for effects and overlays on the camera feed, or the [low-level frame API](./low-level-frame-api.mdx) when you produce the frames yourself.
+The published track carries the camera frames exactly as captured. Anything you draw on top of the preview (React Native views, a [Skia](https://shopify.github.io/react-native-skia/) canvas, or any other overlay) appears only in your local UI and is **not** sent to Fishjam. To render content into the published video itself, use [WebGPU effects with VisionCamera](./webgpu-effects.mdx) for effects and overlays on the camera feed, or the [low-level frame API](../../how-to/client/custom-sources/low-level-frame-api.mdx) when you produce the frames yourself.
:::
@@ -132,6 +132,6 @@ The options also accept VisionCamera's `FrameOutputOptions`. The hook forces `pi
## Next steps
-- Follow the [Vision Camera tutorial](../../../tutorials/vision-camera) for a step-by-step walkthrough
+- Follow the [Vision Camera tutorial](./stream-vision-camera) for a step-by-step walkthrough
- Draw your own content into the feed with [WebGPU effects](./webgpu-effects)
-- API reference: [Vision Camera Source package](../../../api/vision-camera-source/index)
+- API reference: [Vision Camera Source package](../../api/vision-camera-source/index)
diff --git a/docs/integrations/vision-camera/webgpu-effects.mdx b/docs/integrations/vision-camera/webgpu-effects.mdx
new file mode 100644
index 00000000..87b238dc
--- /dev/null
+++ b/docs/integrations/vision-camera/webgpu-effects.mdx
@@ -0,0 +1,228 @@
+---
+title: "Render WebGPU effects into a Vision Camera feed"
+sidebar_position: 3
+sidebar_label: "WebGPU effects π±"
+description: Draw your own shaders, overlays and effects into a react-native-vision-camera feed you publish to Fishjam.
+---
+
+# Render WebGPU effects into a Vision Camera feed Mobile
+
+:::note
+This guide is exclusively for **Mobile** (React Native) applications.
+:::
+
+This guide shows how to render each frame of a published [VisionCamera](https://react-native-vision-camera.com) feed yourself with WebGPU, using the `/webgpu` entry point of `@fishjam-cloud/react-native-vision-camera-source`. For every camera frame, your worklet receives the camera as a GPU texture and an output texture to draw into. The [`useVisionCameraWebGpuSource`](../../api/vision-camera-source/index.md) hook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
+
+:::tip[Don't need VisionCamera?]
+The same kind of rendering works on the camera Fishjam manages itself, without VisionCamera. See [Render WebGPU effects into the camera](../../how-to/client/camera-effects/webgpu-effects.mdx).
+:::
+
+Every camera frame reaches your `onFrame` worklet. Calling `render(...)` gives you the live camera as a GPU texture and an output texture to draw into. Whatever you draw is what peers receive.
+
+## Prerequisites
+
+On top of the [Vision Camera setup](./vision-camera-source.mdx#prerequisites):
+
+- `react-native-webgpu` β₯ 0.10.1
+- `unplugin-typegpu` in your app's Babel config (the TGSL shaders need its build-time transform)
+- iOS 17+: the camera-import path relies on Metal external-texture features not guaranteed on earlier versions. The [base integration](./vision-camera-source.mdx) has no such requirement, so you can keep your `ios.deploymentTarget` unchanged and gate WebGPU usage at runtime.
+
+```bash npm2yarn
+npm install react-native-webgpu typegpu unplugin-typegpu
+```
+
+```js title='babel.config.js'
+module.exports = {
+ presets: ["babel-preset-expo"],
+ plugins: ["unplugin-typegpu/babel", "react-native-worklets/plugin"],
+};
+```
+
+Keep `react-native-worklets/plugin` as the last plugin.
+
+## Get a camera-capable GPU device
+
+`useCameraWebGpuDevice` returns an app-wide shared `GPUDevice`, requested with the features the camera-import path needs:
+
+```tsx
+import { useCameraWebGpuDevice } from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+// ---cut---
+const { device, error } = useCameraWebGpuDevice();
+```
+
+To use your own device instead, pass it as the `device` option of `useVisionCameraWebGpuSource`. It is validated against `getRequiredWebGpuCameraFeatures()`; a device missing any of the features surfaces a descriptive `error` instead of failing per frame.
+
+## Publish the camera through your own shaders
+
+:::tip[First time?]
+The [WebGPU effects tutorial](../../tutorials/webgpu-effects.mdx) builds the same kind of pipeline step by step on Fishjam's own camera, from a passthrough render pass to a watermark overlay and a color effect. The shaders and render passes carry over; only the setup around them differs.
+:::
+
+The example below publishes the camera in grayscale. To apply your own effect, replace the fragment stage. Your component only does the drawing; the hook handles publishing, GPU synchronization with the video encoder, timestamps and frame lifetimes.
+
+:::note
+The shaders are written in [TypeGPU](https://docs.swmansion.com/TypeGPU/) (TGSL): typed TypeScript functions compiled to WGSL by `unplugin-typegpu`. TypeGPU is not required; you can hand-write WGSL and prepend the bindings' `bindingDeclarations` yourself.
+:::
+
+```tsx
+// @baseUrl: .
+// @paths: {"typegpu": ["packages/web-client-sdk/node_modules/typegpu"], "typegpu/*": ["packages/web-client-sdk/node_modules/typegpu/*"]}
+import React, { useCallback, useMemo } from "react";
+import tgpu from "typegpu";
+import * as d from "typegpu/data";
+import { dot } from "typegpu/std";
+import {
+ useCamera as useVisionCamera,
+ useCameraPermission,
+ type Frame,
+} from "react-native-vision-camera";
+import { RTCView } from "@fishjam-cloud/react-native-client";
+import {
+ useVisionCameraWebGpuSource,
+ useCameraWebGpuDevice,
+ createCameraShaderBindings,
+ getOutputSurfaceFormat,
+ type WebGpuFrameRenderFunction,
+} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+
+// Full-screen triangle; uv spans the visible area.
+const vertexMain = tgpu.vertexFn({
+ in: { vertexIndex: d.builtin.vertexIndex },
+ out: { position: d.builtin.position, uv: d.location(0, d.vec2f) },
+})((input) => {
+ const positions = [d.vec2f(-1, -1), d.vec2f(3, -1), d.vec2f(-1, 3)];
+ const p = positions[input.vertexIndex];
+ return {
+ position: d.vec4f(p.x, p.y, 0, 1),
+ uv: d.vec2f((p.x + 1) * 0.5, 1 - (p.y + 1) * 0.5),
+ };
+});
+
+export function GrayscaleCameraPublisher() {
+ const { hasPermission } = useCameraPermission();
+ const { device } = useCameraWebGpuDevice();
+
+ const effect = useMemo(() => {
+ if (device == null) return null;
+
+ const cameraBindings = createCameraShaderBindings(device);
+ const fragmentMain = tgpu.fragmentFn({
+ in: { uv: d.location(0, d.vec2f) },
+ out: d.vec4f,
+ })((input) => {
+ const color = cameraBindings.sampleCamera(input.uv);
+ const gray = dot(color.xyz, d.vec3f(0.299, 0.587, 0.114));
+ return d.vec4f(gray, gray, gray, 1);
+ });
+
+ // TypeGPU cannot emit the external-texture binding itself, so prepend
+ // cameraBindings.bindingDeclarations to the resolved WGSL.
+ const module = device.createShaderModule({
+ code:
+ cameraBindings.bindingDeclarations +
+ tgpu.resolve({ externals: { vertexMain, fragmentMain } }),
+ });
+ const pipeline = device.createRenderPipeline({
+ layout: device.createPipelineLayout({
+ bindGroupLayouts: [cameraBindings.bindGroupLayout],
+ }),
+ vertex: { module, entryPoint: "vertexMain" },
+ fragment: {
+ module,
+ entryPoint: "fragmentMain",
+ targets: [{ format: getOutputSurfaceFormat() }],
+ },
+ });
+ return { cameraBindings, pipeline };
+ }, [device]);
+
+ const onFrame = useCallback(
+ (frame: Frame, render: WebGpuFrameRenderFunction) => {
+ "worklet";
+ if (effect == null) return; // drop frames until the pipeline is ready
+ render(({ commandEncoder, outputView, cameraBindGroup }) => {
+ const pass = commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: outputView, loadOp: "clear", storeOp: "store" },
+ ],
+ });
+ pass.setPipeline(effect.pipeline);
+ pass.setBindGroup(0, cameraBindGroup!);
+ pass.draw(3);
+ pass.end();
+ });
+ },
+ [effect],
+ );
+
+ const { frameOutput, stream } = useVisionCameraWebGpuSource("my-camera", {
+ width: 720,
+ height: 1280,
+ cameraShaderBindings: effect?.cameraBindings,
+ onFrame,
+ });
+
+ useVisionCamera({
+ device: "front",
+ isActive: hasPermission,
+ outputs: [frameOutput],
+ });
+
+ if (!stream) return null;
+ return (
+
+ );
+}
+```
+
+How the example works:
+
+- `createCameraShaderBindings(device)` gives your shaders `sampleCamera(uv)`, which returns upright RGB on both platforms and handles the YUV decode for you.
+- Everything created from the `device` (bindings, shaders, pipeline) lives in one `useMemo` keyed by the device. TypeGPU cannot emit the camera's `texture_external` binding, so `cameraBindings.bindingDeclarations` is prepended to the resolved WGSL, and the fragment targets `getOutputSurfaceFormat()` (`rgba8unorm` on Android, `bgra8unorm` on iOS).
+- Passing `cameraShaderBindings` to the hook makes the render context carry a ready-made `cameraBindGroup`, rebuilt each frame because the camera's external texture expires with every frame.
+- The worklet encodes one render pass; the hook submits it and synchronizes with the video encoder. `frameOutput` plugs into VisionCamera's `useCamera`; `stream` is the self-view.
+
+Other peers receive the feed among their [`customVideoTracks`](../../how-to/client/custom-sources/index.mdx#receiving-custom-tracks).
+
+## Rules inside `onFrame`
+
+The callback you pass to `render(...)` receives a `WebGpuFrameRenderContext` with the `device`, `queue`, `commandEncoder`, the live `cameraTexture` (a `GPUExternalTexture`), the output surface (`outputTexture`, `outputView`, `outputWidth`, `outputHeight`), and camera metadata (`cameraWidth`, `cameraHeight`, `cameraIsMirrored`).
+
+:::warning[Rules your worklet must follow]
+
+- **Always draw into the provided `outputView`.** Calling `outputTexture.createView()` per frame leaks native wrappers on the frame runtime, because `GPUTextureView` has no release API.
+- **Call `render(...)` at most once per frame.** Skipping it drops the frame; nothing is published for it.
+- **Don't call `queue.submit()` yourself.** The hook submits your passes and synchronizes with the video encoder.
+- **Finish GPU uploads before frames flow.** Helpers like [`queue.copyExternalImageToTexture`](https://developer.mozilla.org/en-US/docs/Web/API/GPUQueue/copyExternalImageToTexture) submit work internally. Running them from the JS thread while the source is active races the hook's submissions and can crash the app, so upload textures before activating the camera.
+- **Camera bind groups cannot be cached across frames**, because the external texture changes every frame. The hook rebuilds `cameraBindGroup` for you; if you build your own, call `createCameraBindGroup` inside the worklet each frame.
+- **Keep `onFrame`'s identity stable** (`useCallback` or module scope).
+
+:::
+
+After `render(...)` returns you may keep using the `frame` (for example, to run inference on it), but only until your callback returns; the hook releases it afterwards.
+
+## Going further
+
+- **Overlays**: a frame may contain more than one render pass. Encode additional passes into the same `outputView` (with `loadOp: "load"`) after the camera pass to draw watermarks or other content on top.
+- **Cropping helpers**: the grayscale example stretches the camera to the output. `computeAspectFillCrop` and `computeSquareCrop` compute the crop that fills your output aspect ratio (like `objectFit: "cover"`); `packFrameCropParams` packs a crop for your own uniform buffers.
+- **Pipelines that cannot sample `texture_external`** can resolve the camera into an owned `rgba8unorm` texture with `createCameraTextureResolver` and `resolveCameraTexture`, at the cost of one extra render pass per frame.
+
+The full toolkit is documented in the [Custom Video Source API reference](../../api/custom-video-source/index.md).
+
+## Platform notes
+
+- `sampleCamera(uv)` returns upright RGB on both platforms. On **Android** it performs the BT.709 limited-range YUVβRGB decode in-shader; on **iOS** the camera already arrives as RGB.
+- `getOutputSurfaceFormat()` returns the published surface format: `rgba8unorm` on Android, `bgra8unorm` on iOS. Use it for your fragment targets instead of hard-coding a format.
+- The context's `cameraIsMirrored` tells you whether the camera feed is mirrored (typically the front camera).
+
+## Related guides
+
+- [Publish a Vision Camera feed](./vision-camera-source.mdx): publish the camera without custom rendering
+- [Render WebGPU effects into the camera](../../how-to/client/camera-effects/webgpu-effects.mdx): the same approach on Fishjam's own camera
+- [Low-level frame API](../../how-to/client/custom-sources/low-level-frame-api.mdx): the pooled-surface layer the hook builds on
+- [How custom sources work](../../explanation/custom-sources.mdx)
+- API reference: [Vision Camera Source package](../../api/vision-camera-source/index.md), [Custom Video Source package](../../api/custom-video-source/index.md)
diff --git a/docs/tutorials/background-blur.mdx b/docs/tutorials/background-blur.mdx
new file mode 100644
index 00000000..bd2f2eaa
--- /dev/null
+++ b/docs/tutorials/background-blur.mdx
@@ -0,0 +1,485 @@
+---
+type: tutorial
+sidebar_position: 5
+sidebar_label: "Add background blur to your camera π±"
+description: Step-by-step guide to blurring the background of the camera you publish to Fishjam, with @fishjam-cloud/video-effects.
+---
+
+import TabItem from "@theme/TabItem";
+import Tabs from "@theme/Tabs";
+
+# Add background blur to your camera Mobile
+
+:::note
+This tutorial is exclusively for **Mobile** (React Native) applications.
+:::
+
+This tutorial continues from the [React Native Quick Start](./react-native-quick-start). You'll take the video call app you built there and add a button that blurs the background of your camera, for you and for everyone else in the room.
+
+## What you'll build
+
+The Quick Start video call app with a **Blur on / Blur off** button. When blur is on, other participants see you sharp in front of a blurred background.
+
+## What you'll learn
+
+- How to install and configure `@fishjam-cloud/video-effects`
+- How to load the bundled segmentation model
+- How to turn an effect into a camera middleware and switch it on and off with `useCamera`
+
+## Prerequisites
+
+- The finished app from the [React Native Quick Start](./react-native-quick-start), on Expo SDK 57 (React Native 0.86) with the New Architecture enabled
+- `@fishjam-cloud/react-native-client` **0.30.2** or newer, `@fishjam-cloud/video-effects` **0.1.5** or newer and `react-native-webgpu` **0.10.1** or newer
+- A physical device (iOS 16.4+ or Android 8.0+), or the iOS Simulator with a virtual camera from [SimCam](../how-to/client/ios-simulator-camera.mdx)
+- A second device or simulator to join the room and see the result
+
+## Step 1: Install and configure
+
+### Install the packages
+
+```bash npm2yarn
+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
+```
+
+- `@fishjam-cloud/video-effects` contains the blur effect and the model that finds you in the picture.
+- `@fishjam-cloud/react-native-worklets` and `react-native-worklets` run the effect on every camera frame, off the JS thread.
+- `react-native-webgpu` gives the effect a GPU to draw with.
+- `expo-asset` and `expo-file-system` load the model file that ships inside `@fishjam-cloud/video-effects`.
+- `expo-build-properties` raises the Android minimum SDK version.
+
+### Update the Babel config
+
+The effect code needs the worklets Babel plugin, and TypeGPU (the GPU library it is written with) needs its own plugin, class static blocks and `import.meta`. Replace your `babel.config.js`, keeping `react-native-worklets/plugin` last:
+
+```js title='babel.config.js'
+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",
+ ],
+ };
+};
+```
+
+### Let Metro bundle the model
+
+The model ships as a `.ssgbin` file, which Metro doesn't bundle by default. Create `metro.config.js` in your project root:
+
+```js title='metro.config.js'
+const { getDefaultConfig } = require("expo/metro-config");
+
+const config = getDefaultConfig(__dirname);
+
+config.resolver.assetExts = [...config.resolver.assetExts, "ssgbin"];
+
+module.exports = config;
+```
+
+### Raise the minimum OS versions
+
+Expo SDK 57 needs iOS 16.4, and `react-native-webgpu` needs Android 8.0 (API 26), while Expo builds for API 24 by default. Set both in `app.json`: the iOS version in the Fishjam config plugin, the Android one with `expo-build-properties`:
+
+```json title='app.json'
+{
+ "expo": {
+ "plugins": [
+ [
+ "@fishjam-cloud/react-native-client",
+ {
+ "ios": {
+ "iphoneDeploymentTarget": "16.4"
+ }
+ }
+ ],
+ [
+ "expo-build-properties",
+ {
+ "android": {
+ "minSdkVersion": 26
+ }
+ }
+ ]
+ ]
+ }
+}
+```
+
+If you already configure the Fishjam plugin, add `iphoneDeploymentTarget` to its existing `ios` options.
+
+Without the Android setting, the Android build fails with `'AHardwareBuffer_allocate' is unavailable: introduced in Android 26`.
+
+### Rebuild the app
+
+The new packages contain native code, so a JS reload is not enough:
+
+
+
+
+```bash
+npx expo prebuild
+npx expo run:ios # or run:android
+```
+
+
+
+
+```bash
+cd ios && pod install
+```
+
+
+
+
+Start Metro with a cleared cache afterwards (`npx expo start --clear`), so the new Babel config is picked up.
+
+## Step 2: Load the segmentation model
+
+To blur the background, the effect first has to find you in every frame. A segmentation model does that on the GPU. Create `effects.ts` next to `App.tsx` and set up the model:
+
+```ts title='effects.ts'
+import { typeGpuPersonSegmentation } from "@fishjam-cloud/video-effects/segmentation/typegpu";
+import { Asset } from "expo-asset";
+import { File } from "expo-file-system";
+
+const segmentationModel = Asset.fromModule(
+ require("@fishjam-cloud/video-effects/assets/selfie_segmenter.ssgbin"),
+);
+
+// Android release builds can't fetch a bundled asset by URL, so read it from a local copy.
+async function loadSegmentationModel(): Promise {
+ await segmentationModel.downloadAsync();
+ if (!segmentationModel.localUri) {
+ throw new Error("The segmentation model is not available.");
+ }
+ return new File(segmentationModel.localUri).arrayBuffer();
+}
+
+const segmentation = typeGpuPersonSegmentation({
+ loadModel: loadSegmentationModel,
+});
+```
+
+Nothing is loaded yet: `typeGpuPersonSegmentation` only describes where the model comes from. The model is loaded the first time you switch blur on.
+
+## Step 3: Create the blur middleware
+
+Fishjam lets you put a [middleware](../how-to/client/camera-effects/stream-middleware.mdx) between your camera and the room: a function that receives the camera track and returns the track to publish instead. `createCameraEffectMiddleware` turns an effect into such a middleware.
+
+Add the blur to the end of `effects.ts`:
+
+```ts title='effects.ts'
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+// ---cut---
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+
+export const backgroundBlur = createCameraEffectMiddleware(
+ createBackgroundBlurEffect(() => ({ segmentation, radius: 24 })),
+);
+```
+
+`radius` sets how strong the blur is, from `0` to `40`. The middleware is created once, at module scope, so it keeps the same identity for the whole app. You'll use that in the next step to tell whether blur is on.
+
+## Step 4: Add a blur button
+
+`useCamera` gives you two things for middleware: `setCameraTrackMiddleware` to set it, and `currentCameraMiddleware` to read what is set. Blur is on when the current middleware is `backgroundBlur`. Add a button component to `App.tsx`:
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const backgroundBlur: NonNullable;
+// ---cut---
+import React from "react";
+import { Button } from "react-native";
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+function BlurButton() {
+ const { currentCameraMiddleware, setCameraTrackMiddleware } = useCamera();
+ const isBlurOn = currentCameraMiddleware === backgroundBlur;
+
+ const toggleBlur = async () => {
+ try {
+ await setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur);
+ } catch (error) {
+ console.warn("Background blur failed", error);
+ await setCameraTrackMiddleware(null); // go back to the plain camera
+ }
+ };
+
+ return (
+
+ );
+}
+```
+
+Then render it in `VideoCall`, right above your own video:
+
+```tsx
+import React from "react";
+import { View, Text } from "react-native";
+import type { MediaStream } from "@fishjam-cloud/react-native-client";
+declare function BlurButton(): React.JSX.Element;
+declare function VideoPlayer(props: { stream: MediaStream }): React.JSX.Element;
+declare const cameraStream: MediaStream | null;
+declare const styles: { section: object; sectionTitle: object };
+const view = (
+ <>
+ // ---cut---
+ {cameraStream && (
+
+ Your Video
+
+
+
+ )}
+ // ---cut-after---
+ >
+);
+```
+
+Rebuild, join the room and tap **Blur on**. The first time takes a moment, because the model is loaded and the GPU pipelines are built. Until blur is ready, your camera keeps being published as before, so the video never goes black.
+
+Then join the same room from a second device. Its view of you shows the blurred background too: the blur is part of the camera track you publish, so the receiving side needs no changes.
+
+A few things to notice:
+
+- Blur stays on when the component that set it unmounts, because the middleware lives in Fishjam's camera state. Pass `null` to turn it off.
+- Blur follows the camera. It is applied again when you switch between the front and back camera, or stop and restart the camera.
+- If the device can't run the effect, `setCameraTrackMiddleware` rejects. The `catch` block then goes back to the plain camera, because at that point `currentCameraMiddleware` already points at the blur.
+
+## Step 5: Report model loading problems
+
+A failed model load doesn't reject `setCameraTrackMiddleware`: the middleware is still applied, but publishes the camera without blur. To find out about it, pass `onStatus` to the middleware. Update `backgroundBlur` in `effects.ts`:
+
+```ts title='effects.ts'
+import type { PersonSegmentationProvider } from "@fishjam-cloud/video-effects";
+declare const segmentation: PersonSegmentationProvider;
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+// ---cut---
+export const backgroundBlur = createCameraEffectMiddleware(
+ createBackgroundBlurEffect(() => ({ segmentation, radius: 24 })),
+ {
+ onStatus: (status, error) => {
+ if (status === "error") console.warn("Background blur failed", error);
+ },
+ },
+);
+```
+
+`onStatus` is called with `"loading"` when the model starts loading, `"ready"` when blur is running, and `"error"` with the error if the model can't be loaded.
+
+## Complete example
+
+`effects.ts`:
+
+```ts title='effects.ts'
+import { createBackgroundBlurEffect } from "@fishjam-cloud/video-effects/background-blur";
+import { createCameraEffectMiddleware } from "@fishjam-cloud/video-effects/fishjam-react-native";
+import { typeGpuPersonSegmentation } from "@fishjam-cloud/video-effects/segmentation/typegpu";
+import { Asset } from "expo-asset";
+import { File } from "expo-file-system";
+
+const segmentationModel = Asset.fromModule(
+ require("@fishjam-cloud/video-effects/assets/selfie_segmenter.ssgbin"),
+);
+
+// Android release builds can't fetch a bundled asset by URL, so read it from a local copy.
+async function loadSegmentationModel(): Promise {
+ await segmentationModel.downloadAsync();
+ if (!segmentationModel.localUri) {
+ throw new Error("The segmentation model is not available.");
+ }
+ return new File(segmentationModel.localUri).arrayBuffer();
+}
+
+const segmentation = typeGpuPersonSegmentation({
+ loadModel: loadSegmentationModel,
+});
+
+export const backgroundBlur = createCameraEffectMiddleware(
+ createBackgroundBlurEffect(() => ({ segmentation, radius: 24 })),
+ {
+ onStatus: (status, error) => {
+ if (status === "error") console.warn("Background blur failed", error);
+ },
+ },
+);
+```
+
+`App.tsx`, the Quick Start app with `BlurButton` added:
+
+```tsx title='App.tsx'
+// @filename: effects.ts
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+export declare const backgroundBlur: NonNullable;
+// @filename: App.tsx
+// ---cut---
+import React, { useState } from "react";
+import { View, Text, Button, ScrollView, StyleSheet } from "react-native";
+import {
+ FishjamProvider,
+ useConnection,
+ useCamera,
+ usePeers,
+ useInitializeDevices,
+ useSandbox,
+ RTCView,
+ type MediaStream,
+} from "@fishjam-cloud/react-native-client";
+import { backgroundBlur } from "./effects";
+
+const FISHJAM_ID = "YOUR_FISHJAM_ID";
+const SANDBOX_API_URL = "YOUR_SANDBOX_API_URL";
+
+function VideoPlayer({ stream }: { stream: MediaStream | null | undefined }) {
+ if (!stream) {
+ return (
+
+ No video
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function BlurButton() {
+ const { currentCameraMiddleware, setCameraTrackMiddleware } = useCamera();
+ const isBlurOn = currentCameraMiddleware === backgroundBlur;
+
+ const toggleBlur = async () => {
+ try {
+ await setCameraTrackMiddleware(isBlurOn ? null : backgroundBlur);
+ } catch (error) {
+ console.warn("Background blur failed", error);
+ await setCameraTrackMiddleware(null); // go back to the plain camera
+ }
+ };
+
+ return (
+
+ );
+}
+
+function VideoCall() {
+ const { joinRoom, peerStatus } = useConnection();
+ const { cameraStream } = useCamera();
+ const { remotePeers } = usePeers();
+ const { initializeDevices } = useInitializeDevices();
+ const { getSandboxPeerToken } = useSandbox({
+ sandboxApiUrl: SANDBOX_API_URL,
+ });
+
+ const [isJoined, setIsJoined] = useState(false);
+
+ const handleJoin = async () => {
+ const roomName = "testRoom";
+ const peerName = `user_${Date.now()}`;
+
+ // Initialize devices first
+ await initializeDevices();
+
+ // For testing with the Sandbox API, use getSandboxPeerToken
+ // For production apps, get the peerToken from your own backend instead
+ const peerToken = await getSandboxPeerToken(roomName, peerName);
+
+ await joinRoom({ peerToken });
+ setIsJoined(true);
+ };
+
+ return (
+
+ Fishjam Video Call
+ Status: {peerStatus}
+
+ {!isJoined && }
+
+ {cameraStream && (
+
+ Your Video
+
+
+
+ )}
+
+
+ Other Participants
+ {remotePeers.length === 0 ? (
+ No other participants
+ ) : (
+ remotePeers.map((peer) => (
+
+ {peer.cameraTrack?.stream && (
+
+ )}
+
+ ))
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 20,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: "bold",
+ marginBottom: 10,
+ },
+ status: {
+ fontSize: 16,
+ marginBottom: 20,
+ },
+ section: {
+ marginTop: 20,
+ },
+ sectionTitle: {
+ fontSize: 18,
+ fontWeight: "600",
+ marginBottom: 10,
+ },
+ participant: {
+ marginBottom: 10,
+ },
+ video: {
+ height: 200,
+ width: "100%",
+ borderRadius: 8,
+ },
+ videoPlaceholder: {
+ height: 200,
+ width: "100%",
+ backgroundColor: "#000",
+ borderRadius: 8,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+});
+
+export default function App() {
+ return (
+
+
+
+ );
+}
+```
+
+## Next steps
+
+- Replace the background with an image, change the blur strength, or scope the effect to one screen; see [Background effects](../how-to/client/camera-effects/background-effects.mdx)
+- Draw your own shaders into the camera in the [WebGPU effects tutorial](./webgpu-effects)
+- Learn [how camera effects work](../explanation/camera-effects.mdx) under the hood
+- API reference: [Video Effects package](../api/video-effects/index.md)
diff --git a/docs/tutorials/react-native-quick-start.mdx b/docs/tutorials/react-native-quick-start.mdx
index 6aa7be57..be5dbf7f 100644
--- a/docs/tutorials/react-native-quick-start.mdx
+++ b/docs/tutorials/react-native-quick-start.mdx
@@ -449,6 +449,7 @@ export default function App() {
Now that you have a basic app working, explore these how-to guides:
- [How to handle screen sharing](../how-to/client/screensharing)
+- [How to blur your camera background](./background-blur)
- [How to implement background streaming](../how-to/client/background-streaming)
- [How to handle reconnections](../how-to/client/reconnection-handling)
- [How to work with metadata](../how-to/client/metadata)
diff --git a/docs/tutorials/react-quick-start.mdx b/docs/tutorials/react-quick-start.mdx
index e807e1a7..b8c3c85b 100644
--- a/docs/tutorials/react-quick-start.mdx
+++ b/docs/tutorials/react-quick-start.mdx
@@ -288,7 +288,7 @@ Now that you have a basic app working, explore these how-to guides:
- [How to manage media devices](../how-to/client/managing-devices)
- [How to implement livestreaming](../tutorials/livestreaming)
-- [How to work with stream middleware](../how-to/client/stream-middleware)
+- [How to work with stream middleware](../how-to/client/camera-effects/stream-middleware)
- [How to handle custom sources](../how-to/client/custom-sources/web)
- [Explore React examples](../examples/react)
diff --git a/docs/tutorials/webgpu-effects.mdx b/docs/tutorials/webgpu-effects.mdx
index 1c077af8..9ec032f5 100644
--- a/docs/tutorials/webgpu-effects.mdx
+++ b/docs/tutorials/webgpu-effects.mdx
@@ -11,50 +11,89 @@ import Tabs from "@theme/Tabs";
# Draw WebGPU effects into your published camera Mobile
:::note
-This tutorial is exclusively for **Mobile** (React Native) applications. On the web, publishing WebGPU output only requires capturing a canvas; see the web tab of the [WebGPU effects how-to](../how-to/client/custom-sources/webgpu-effects).
+This tutorial is exclusively for **Mobile** (React Native) applications. On the web, publishing WebGPU output only requires capturing a canvas; see the web tab of the [WebGPU effects how-to](../how-to/client/camera-effects/webgpu-effects).
:::
-This tutorial continues from [Stream Vision Camera to Fishjam](./vision-camera). You'll take the app you built there and route its published camera through your own WebGPU pipeline: first unchanged, then with a watermark image drawn on top, and finally recolored by a shader you write yourself.
+This tutorial continues from the [React Native Quick Start](./react-native-quick-start). You'll take the video call app you built there and route its camera through your own WebGPU pipeline: first unchanged, then with a watermark image drawn on top, and finally recolored by a shader you write yourself.
## What you'll build
-The Vision Camera app, now publishing through your own GPU pipeline: a camera feed with a "LIVE" watermark in the corner, recolored to grayscale by your own shader.
+The Quick Start app, now publishing its camera through your own GPU pipeline: a camera feed with a "LIVE" watermark in the corner, recolored to grayscale by your own shader, switched on and off with a button.
## What you'll learn
-- How to route published camera frames through `useVisionCameraWebGpuSource`
+- How to render the published camera yourself with a camera middleware and `createCameraFrameProcessorSession`
- How to load a PNG into a GPU texture and composite it over the camera as a second render pass
- How to write TypeGPU shaders that change the published camera pixels
## Prerequisites
-- The finished app from the [Vision Camera tutorial](./vision-camera); this tutorial rewrites only its `CameraPublisher`, while `JoinRoomButton`, `RemoteStreams` and `App` stay unchanged
-- iOS 17+ on the publishing device; the WebGPU camera path relies on Metal features not guaranteed on earlier versions (the setup from the previous tutorial keeps working below that)
-- A physical device, or the iOS Simulator with a virtual camera from [SimCam](../how-to/client/ios-simulator-camera.mdx)
-- As before: the New Architecture
+- The finished app from the [React Native Quick Start](./react-native-quick-start), on Expo SDK 57 (React Native 0.86) with the New Architecture enabled
+- `@fishjam-cloud/react-native-client` **0.30.2** or newer, `@fishjam-cloud/video-effects` **0.1.5** or newer and `react-native-webgpu` **0.10.1** or newer
+- A physical device (iOS 16.4+ or Android 8.0+), or the iOS Simulator with a virtual camera from [SimCam](../how-to/client/ios-simulator-camera.mdx)
+- A second device or simulator to join the room and see the result
## Step 1: Install and configure
### Install the packages
-On top of the packages from the previous tutorial:
-
```bash npm2yarn
-npm install react-native-webgpu typegpu unplugin-typegpu
+npm install @fishjam-cloud/video-effects @fishjam-cloud/react-native-worklets react-native-worklets react-native-webgpu typegpu expo-asset expo-file-system expo-build-properties
+npm install --save-dev unplugin-typegpu @babel/plugin-transform-class-static-block
```
+- `@fishjam-cloud/video-effects` hands you every camera frame as a GPU texture and publishes what you draw.
+- `@fishjam-cloud/react-native-worklets` and `react-native-worklets` run your drawing code on every camera frame, off the JS thread.
+- `react-native-webgpu` is the GPU API you draw with, and `typegpu` lets you write shaders in TypeScript.
+- `expo-asset` and `expo-file-system` load the watermark image in Step 3.
+- `expo-build-properties` raises the Android minimum SDK version.
+
### Update the Babel config
-The shaders in this tutorial are written in [TypeGPU](https://docs.swmansion.com/TypeGPU/) (TGSL): typed TypeScript functions compiled to WGSL at build time by `unplugin-typegpu`. Add its Babel plugin, keeping `react-native-worklets/plugin` last:
+The shaders in this tutorial are written in [TypeGPU](https://docs.swmansion.com/TypeGPU/) (TGSL): typed TypeScript functions compiled to WGSL at build time by `unplugin-typegpu`. TypeGPU also needs class static blocks and `import.meta`, and your drawing code needs the worklets plugin. Replace your `babel.config.js`, keeping `react-native-worklets/plugin` last:
```js title='babel.config.js'
-module.exports = {
- presets: ["babel-preset-expo"],
- plugins: ["unplugin-typegpu/babel", "react-native-worklets/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",
+ ],
+ };
};
```
-Restart Metro with a cleared cache afterwards (`npx expo start --clear`).
+### Raise the minimum OS versions
+
+Expo SDK 57 needs iOS 16.4, and `react-native-webgpu` needs Android 8.0 (API 26), while Expo builds for API 24 by default. Set both in `app.json`: the iOS version in the Fishjam config plugin, the Android one with `expo-build-properties`:
+
+```json title='app.json'
+{
+ "expo": {
+ "plugins": [
+ [
+ "@fishjam-cloud/react-native-client",
+ {
+ "ios": {
+ "iphoneDeploymentTarget": "16.4"
+ }
+ }
+ ],
+ [
+ "expo-build-properties",
+ {
+ "android": {
+ "minSdkVersion": 26
+ }
+ }
+ ]
+ ]
+ }
+}
+```
### Rebuild the app
@@ -76,103 +115,107 @@ cd ios && pod install
+Start Metro with a cleared cache afterwards (`npx expo start --clear`).
+
## Step 2: Route the camera through WebGPU
-Replace `useVisionCameraSource` with `useVisionCameraWebGpuSource`. This changes three things:
+Fishjam lets you put a [middleware](../how-to/client/camera-effects/stream-middleware.mdx) between your camera and the room: a function that receives the camera track and returns the track to publish instead. Your middleware will return a track that you draw yourself. Three pieces make that work:
-- `useCameraWebGpuDevice` provides a shared `GPUDevice`, requested with the features the camera-import path needs.
-- Every camera frame now reaches your `onFrame` worklet, where calling `render(...)` gives you a context with the command encoder, the live camera texture and the output texture. What you draw into the output texture is what peers receive.
+- `getCameraWebGpuDevice` provides a shared `GPUDevice`, requested with the features the camera import needs.
+- `createCameraFrameProcessorSession` calls your `frameKernel` worklet for every camera frame. Calling `render(...)` inside it gives you a context with the command encoder, the live camera texture and the output texture. What you draw into the output texture is what peers receive.
- For now, draw the camera unchanged with the ready-made passthrough pass: `createCameraPassthroughPipeline` builds it once, `encodeCameraPassthrough` encodes it each frame, and `computeAspectFillCrop` fills the output like `objectFit: "cover"`.
-Keep the source ID `"vision-camera"`, so `RemoteStreams` keeps working without changes:
+Create `cameraEffect.ts` next to `App.tsx`:
-```tsx
-import React, { useCallback, useEffect, useMemo } from "react";
-import { Text } from "react-native";
+```ts title='cameraEffect.ts'
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
import {
- useCamera as useVisionCamera,
- useCameraPermission,
- type Frame,
-} from "react-native-vision-camera";
-import { RTCView } from "@fishjam-cloud/react-native-client";
-import {
- useVisionCameraWebGpuSource,
- useCameraWebGpuDevice,
+ computeAspectFillCrop,
+ createCameraFrameProcessorSession,
createCameraPassthroughPipeline,
encodeCameraPassthrough,
- computeAspectFillCrop,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+ getCameraWebGpuDevice,
+ type CameraFrameKernel,
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
-export function CameraPublisher() {
- const { hasPermission, canRequestPermission, requestPermission } =
- useCameraPermission();
-
- useEffect(() => {
- if (!hasPermission && canRequestPermission) {
- requestPermission();
- }
- }, [hasPermission, canRequestPermission, requestPermission]);
+export const cameraEffect: TrackMiddleware = async (track) => {
+ const device = await getCameraWebGpuDevice();
- const { device } = useCameraWebGpuDevice();
-
- // A ready-made camera β output render pass, built once per device.
- const passthrough = useMemo(
- () => (device ? createCameraPassthroughPipeline(device) : null),
- [device],
- );
+ // A ready-made camera β output render pass. The Fishjam camera arrives as RGB.
+ const passthrough = createCameraPassthroughPipeline(device, {
+ cameraPixelLayout: "rgb",
+ });
- const onFrame = useCallback(
- (frame: Frame, render: WebGpuFrameRenderFunction) => {
- "worklet";
- if (device == null || passthrough == null) return;
- render((context) => {
- // Crop the camera to fill the output, like `objectFit: "cover"`.
- const crop = computeAspectFillCrop(
- context.cameraWidth,
- context.cameraHeight,
- context.outputWidth / context.outputHeight,
- );
- encodeCameraPassthrough(
- device,
- passthrough,
- context.cameraTexture,
- context.outputView,
- context.commandEncoder,
- crop,
- );
- });
- },
- [device, passthrough],
- );
+ const frameKernel: CameraFrameKernel = (frame, render) => {
+ "worklet";
+ render((context) => {
+ // Crop the camera to fill the output, like `objectFit: "cover"`.
+ const crop = computeAspectFillCrop(
+ context.cameraWidth,
+ context.cameraHeight,
+ context.outputWidth / context.outputHeight,
+ );
+ encodeCameraPassthrough(
+ context.device,
+ passthrough,
+ context.cameraTexture,
+ context.outputView,
+ context.commandEncoder,
+ crop,
+ );
+ });
+ };
- const { frameOutput, stream } = useVisionCameraWebGpuSource("vision-camera", {
+ const session = await createCameraFrameProcessorSession({
+ track,
+ device,
width: 720,
height: 1280,
- onFrame,
+ frameKernel,
});
- useVisionCamera({
- device: "front",
- isActive: hasPermission,
- outputs: [frameOutput],
- });
+ // Publish the session's track; dispose it when the middleware is removed.
+ return { track: session.track, onClear: () => void session.dispose() };
+};
+```
+
+Then add a button to `App.tsx` that switches the middleware on and off, the same way as any camera middleware:
+
+```tsx
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
+declare const cameraEffect: NonNullable;
+// ---cut---
+import React from "react";
+import { Button } from "react-native";
+import { useCamera } from "@fishjam-cloud/react-native-client";
+
+function EffectButton() {
+ const { currentCameraMiddleware, setCameraTrackMiddleware } = useCamera();
+ const isEffectOn = currentCameraMiddleware === cameraEffect;
+
+ const toggleEffect = async () => {
+ try {
+ await setCameraTrackMiddleware(isEffectOn ? null : cameraEffect);
+ } catch (error) {
+ console.warn("Camera effect failed", error);
+ await setCameraTrackMiddleware(null); // go back to the plain camera
+ }
+ };
- if (!stream) return Starting camera⦠;
return (
-
);
}
```
-Rebuild and join the room from a second device. The feed looks the same as before, but every published pixel now flows through a pipeline you control. The next two steps build on it.
+Render ` ` in `VideoCall`, right above your own video (` `).
+
+Rebuild, join the room from two devices and tap **Effect on**. The feed looks the same as before, but every published pixel now flows through a pipeline you control. The next two steps build on it.
-From here on, the [rules inside `onFrame`](../how-to/client/custom-sources/webgpu-effects#rules-inside-onframe) apply: draw into the provided output view, call `render(...)` at most once per frame, and never call `queue.submit()` yourself.
+From here on, the [rules inside the frame kernel](../how-to/client/camera-effects/webgpu-effects#rules-inside-the-frame-kernel) apply: draw into the provided output view, call `render(...)` at most once per frame, and never call `queue.submit()` yourself.
## Step 3: Draw a watermark on top
@@ -188,15 +231,16 @@ Note the following:
- Unlike the camera, which arrives as an external texture that expires every frame, the watermark is a plain `texture_2d`. It's uploaded once with `copyExternalImageToTexture`, and its bind group is built once and reused for every frame.
- The quad's corner placement is computed in plain JavaScript and baked into the vertex shader as constants. The output size is fixed, so no uniform buffer is needed.
-- The `.$name(...)` calls pin the WGSL entry-point names. TypeGPU otherwise names functions after their JavaScript identifiers, and `createRenderPipeline` would look for an entry point that doesn't exist. This failure is silent: WebGPU reports it as an asynchronous validation error, not a thrown exception, and the pass simply draws nothing.
+- `tgpu.resolve` takes the shader functions as an array. The `.$name(...)` calls pin the WGSL entry-point names that `createRenderPipeline` looks up. If a name doesn't match, the failure is silent: WebGPU reports it as an asynchronous validation error, not a thrown exception, and the pass simply draws nothing.
-```tsx
+Add the pipeline to `cameraEffect.ts`:
+
+```ts title='cameraEffect.ts'
///
// ---cut---
import tgpu from "typegpu";
import * as d from "typegpu/data";
-import { GPUShaderStage, GPUTextureUsage } from "react-native-webgpu";
-import { getOutputSurfaceFormat } from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+import { getOutputSurfaceFormat } from "@fishjam-cloud/video-effects/fishjam-react-native";
// A plain 2D texture never expires (unlike the camera's external texture),
// so it is bound once and reused every frame.
@@ -249,7 +293,7 @@ const watermarkFragmentMain = tgpu
})((input) => sampleWatermark(input.uv))
.$name("fragmentMain");
-export function createWatermarkPipeline(
+function createWatermarkPipeline(
device: GPUDevice,
bitmap: ImageBitmap,
outputWidth: number,
@@ -289,12 +333,10 @@ export function createWatermarkPipeline(
const module = device.createShaderModule({
code:
watermarkBindingDeclarations +
- tgpu.resolve({
- externals: {
- vertexMain: makeWatermarkVertexMain({ x0, y0, x1, y1 }),
- fragmentMain: watermarkFragmentMain,
- },
- }),
+ tgpu.resolve([
+ makeWatermarkVertexMain({ x0, y0, x1, y1 }),
+ watermarkFragmentMain,
+ ]),
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
@@ -342,92 +384,75 @@ export function createWatermarkPipeline(
```
:::note
-TypeGPU is not required; you can hand-write the same shaders in WGSL. See the [WebGPU effects how-to](../how-to/client/custom-sources/webgpu-effects) for the trade-offs.
+TypeGPU is not required; you can hand-write the same shaders in WGSL. See the [WebGPU effects how-to](../how-to/client/camera-effects/webgpu-effects) for the trade-offs.
:::
### Load the image and encode the overlay
-Loading is asynchronous: fetch the bundled asset, then decode it with `createImageBitmap` (provided globally by `react-native-webgpu`). It lives in a `useEffect` and lands in state. Inside `CameraPublisher`, add:
+Loading is asynchronous: read the bundled asset with `expo-asset` and `expo-file-system`, then decode it with `createImageBitmap` (provided globally by `react-native-webgpu`). Reading a local copy of the asset instead of fetching it by URL keeps working in Android release builds. There, `expo-asset` leaves a bundled image as a drawable resource name instead of a file, so the loader creates a second `Asset` without the image size, which `downloadAsync` copies to a local file. Add the loader to `cameraEffect.ts`:
-```tsx
+```ts title='cameraEffect.ts'
///
import "react-native-webgpu";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { Image } from "react-native";
-import type { Frame } from "react-native-vision-camera";
+// ---cut---
+import { Asset } from "expo-asset";
+import { File } from "expo-file-system";
+
+const watermarkAsset = Asset.fromModule(require("./assets/watermark.png"));
+
+async function loadWatermarkBitmap(): Promise {
+ // Android release builds keep bundled images as drawable resources, which `File` can't
+ // open. An asset described without the image size is copied to a local file instead.
+ const asset = new Asset({
+ name: watermarkAsset.name,
+ type: watermarkAsset.type,
+ hash: watermarkAsset.hash,
+ uri: watermarkAsset.uri,
+ });
+ await asset.downloadAsync();
+ if (!asset.localUri) {
+ throw new Error("The watermark image is not available.");
+ }
+ const bytes = await new File(asset.localUri).arrayBuffer();
+ return createImageBitmap(bytes);
+}
+```
+
+Then update the middleware. It builds the watermark pipeline before it creates the session, and waits for the GPU to finish the upload. `copyExternalImageToTexture` submits GPU work itself, and once frames flow the session owns the device queue, so a concurrent upload from the JS thread can crash the app. Inside the kernel, after the passthrough and still inside the same `render` callback, encode a second pass on the same command encoder. `loadOp: "load"` keeps the camera pixels underneath, so the quad blends on top:
+
+```ts title='cameraEffect.ts'
+///
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
import {
- useCameraWebGpuDevice,
+ computeAspectFillCrop,
+ createCameraFrameProcessorSession,
createCameraPassthroughPipeline,
encodeCameraPassthrough,
- computeAspectFillCrop,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+ getCameraWebGpuDevice,
+ type CameraFrameKernel,
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
+declare function loadWatermarkBitmap(): Promise;
declare function createWatermarkPipeline(
device: GPUDevice,
bitmap: ImageBitmap,
outputWidth: number,
outputHeight: number,
): { pipeline: GPURenderPipeline; bindGroup: GPUBindGroup };
+// ---cut---
+export const cameraEffect: TrackMiddleware = async (track) => {
+ const device = await getCameraWebGpuDevice();
+ const passthrough = createCameraPassthroughPipeline(device, {
+ cameraPixelLayout: "rgb",
+ });
-export function CameraPublisher() {
- const { device } = useCameraWebGpuDevice();
- const passthrough = useMemo(
- () => (device ? createCameraPassthroughPipeline(device) : null),
- [device],
- );
- // ---cut---
- const [watermark, setWatermark] = useState | null>(null);
-
- useEffect(() => {
- if (device == null) return;
- let cancelled = false;
- const load = async () => {
- const asset = Image.resolveAssetSource(require("./assets/watermark.png"));
- const response = await fetch(asset.uri);
- const bitmap = await createImageBitmap(await response.arrayBuffer());
- if (!cancelled) {
- setWatermark(createWatermarkPipeline(device, bitmap, 720, 1280));
- }
- };
- void load();
- return () => {
- cancelled = true;
- };
- }, [device]);
- // ---cut-after---
-}
-```
-
-Then extend `onFrame`: after the passthrough, still inside the same `render` callback, encode a second pass on the same command encoder. `loadOp: "load"` keeps the camera pixels underneath, so the quad blends on top:
+ // Upload the watermark before frames flow, and wait until the GPU is done.
+ const bitmap = await loadWatermarkBitmap();
+ const watermark = createWatermarkPipeline(device, bitmap, 720, 1280);
+ await device.queue.onSubmittedWorkDone();
-```tsx
-///
-import { useCallback } from "react";
-import type { Frame } from "react-native-vision-camera";
-import {
- useCameraWebGpuDevice,
- createCameraPassthroughPipeline,
- encodeCameraPassthrough,
- computeAspectFillCrop,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
-
-declare const device: ReturnType["device"];
-declare const passthrough: ReturnType<
- typeof createCameraPassthroughPipeline
-> | null;
-declare const watermark: {
- pipeline: GPURenderPipeline;
- bindGroup: GPUBindGroup;
-} | null;
-// ---cut---
-const onFrame = useCallback(
- (frame: Frame, render: WebGpuFrameRenderFunction) => {
+ const frameKernel: CameraFrameKernel = (frame, render) => {
"worklet";
- if (device == null || passthrough == null) return;
render((context) => {
const crop = computeAspectFillCrop(
context.cameraWidth,
@@ -435,7 +460,7 @@ const onFrame = useCallback(
context.outputWidth / context.outputHeight,
);
encodeCameraPassthrough(
- device,
+ context.device,
passthrough,
context.cameraTexture,
context.outputView,
@@ -443,62 +468,42 @@ const onFrame = useCallback(
crop,
);
- if (watermark != null) {
- // `loadOp: "load"` keeps the camera pass underneath.
- const pass = context.commandEncoder.beginRenderPass({
- colorAttachments: [
- { view: context.outputView, loadOp: "load", storeOp: "store" },
- ],
- });
- pass.setPipeline(watermark.pipeline);
- pass.setBindGroup(0, watermark.bindGroup);
- pass.draw(4);
- pass.end();
- }
+ // `loadOp: "load"` keeps the camera pass underneath.
+ const pass = context.commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: context.outputView, loadOp: "load", storeOp: "store" },
+ ],
+ });
+ pass.setPipeline(watermark.pipeline);
+ pass.setBindGroup(0, watermark.bindGroup);
+ pass.draw(4);
+ pass.end();
});
- },
- [device, passthrough, watermark],
-);
-```
-
-The worklet captures the watermark's pipeline and bind group once, which is safe because a plain 2D texture never expires. A single `render(...)` call may contain any number of passes; only calling `render` more than once per frame is not allowed.
-
-One last change: keep the camera inactive until the upload has finished. `copyExternalImageToTexture` submits GPU work internally, and while frames flow the hook owns the device queue, so a concurrent upload from the JS thread can crash the app. Gating `isActive` serializes the two:
+ };
-```tsx
-///
-import {
- useCamera as useVisionCamera,
- useCameraPermission,
-} from "react-native-vision-camera";
-import { useVisionCameraWebGpuSource } from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+ const session = await createCameraFrameProcessorSession({
+ track,
+ device,
+ width: 720,
+ height: 1280,
+ frameKernel,
+ });
-declare const watermark: {
- pipeline: GPURenderPipeline;
- bindGroup: GPUBindGroup;
-} | null;
-declare const frameOutput: ReturnType<
- typeof useVisionCameraWebGpuSource
->["frameOutput"];
-const { hasPermission } = useCameraPermission();
-// ---cut---
-useVisionCamera({
- device: "front",
- // The hook owns the GPU queue while frames flow; wait for the upload.
- isActive: hasPermission && watermark != null,
- outputs: [frameOutput],
-});
+ return { track: session.track, onClear: () => void session.dispose() };
+};
```
-Rebuild and check the receiving side: "LIVE" sits over the bottom-right corner of the camera.
+The worklet captures the watermark's pipeline and bind group once, which is safe because a plain 2D texture never expires. A single `render(...)` call may contain any number of passes; only calling `render` more than once per frame is not allowed.
+
+Rebuild, switch the effect on and check the receiving side: "LIVE" sits over the bottom-right corner of the camera.
## Step 4: Change the colors with your own shader
-An overlay draws on top of the camera pixels. To change the pixels themselves, replace the passthrough with your own pipeline: a full-screen triangle whose fragment shader samples the camera and returns a new color. `createCameraShaderBindings` gives your shader `sampleCamera(uv)`, which returns upright RGB on both platforms and handles the YUV decode for you.
+An overlay draws on top of the camera pixels. To change the pixels themselves, replace the passthrough with your own pipeline: a full-screen triangle whose fragment shader samples the camera and returns a new color. `createCameraShaderBindings` gives your shader `sampleCamera(uv)`, which returns the upright camera color.
-Add the effect next to the watermark code at module scope:
+Add the effect next to the watermark code in `cameraEffect.ts`:
-```tsx
+```ts title='cameraEffect.ts'
///
// ---cut---
import tgpu from "typegpu";
@@ -507,7 +512,7 @@ import { dot } from "typegpu/std";
import {
createCameraShaderBindings,
getOutputSurfaceFormat,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
// Full-screen triangle; uv spans the visible area.
const vertexMain = tgpu.vertexFn({
@@ -522,8 +527,10 @@ const vertexMain = tgpu.vertexFn({
};
});
-export function createGrayscaleEffect(device: GPUDevice) {
- const cameraBindings = createCameraShaderBindings(device);
+function createGrayscaleEffect(device: GPUDevice) {
+ const cameraBindings = createCameraShaderBindings(device, {
+ cameraPixelLayout: "rgb",
+ });
const fragmentMain = tgpu.fragmentFn({
in: { uv: d.location(0, d.vec2f) },
@@ -539,7 +546,7 @@ export function createGrayscaleEffect(device: GPUDevice) {
const module = device.createShaderModule({
code:
cameraBindings.bindingDeclarations +
- tgpu.resolve({ externals: { vertexMain, fragmentMain } }),
+ tgpu.resolve([vertexMain, fragmentMain]),
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
@@ -556,38 +563,40 @@ export function createGrayscaleEffect(device: GPUDevice) {
}
```
-In the component, build the effect once per device and pass its `cameraBindings` to the hook. In return, the render context carries a ready-made `cameraBindGroup`, rebuilt every frame because the camera's external texture expires with each frame. The worklet now encodes the grayscale pass instead of the passthrough, with the watermark pass unchanged on top. The passthrough and crop imports are no longer needed:
+In the middleware, build the effect and pass its `cameraBindings` to the session. In return, the render context carries a ready-made `cameraBindGroup`, rebuilt every frame because the camera's external texture expires with each frame. The kernel now encodes the grayscale pass instead of the passthrough, with the watermark pass unchanged on top. The passthrough and crop imports are no longer needed:
-```tsx
+```ts title='cameraEffect.ts'
///
-import { useCallback, useMemo } from "react";
-import type { Frame } from "react-native-vision-camera";
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
import {
- useVisionCameraWebGpuSource,
- useCameraWebGpuDevice,
+ createCameraFrameProcessorSession,
createCameraShaderBindings,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+ getCameraWebGpuDevice,
+ type CameraFrameKernel,
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
-declare const device: ReturnType["device"];
+declare function loadWatermarkBitmap(): Promise;
+declare function createWatermarkPipeline(
+ device: GPUDevice,
+ bitmap: ImageBitmap,
+ outputWidth: number,
+ outputHeight: number,
+): { pipeline: GPURenderPipeline; bindGroup: GPUBindGroup };
declare function createGrayscaleEffect(device: GPUDevice): {
cameraBindings: ReturnType;
pipeline: GPURenderPipeline;
};
-declare const watermark: {
- pipeline: GPURenderPipeline;
- bindGroup: GPUBindGroup;
-} | null;
// ---cut---
-const effect = useMemo(
- () => (device ? createGrayscaleEffect(device) : null),
- [device],
-);
+export const cameraEffect: TrackMiddleware = async (track) => {
+ const device = await getCameraWebGpuDevice();
+ const grayscale = createGrayscaleEffect(device);
+
+ const bitmap = await loadWatermarkBitmap();
+ const watermark = createWatermarkPipeline(device, bitmap, 720, 1280);
+ await device.queue.onSubmittedWorkDone();
-const onFrame = useCallback(
- (frame: Frame, render: WebGpuFrameRenderFunction) => {
+ const frameKernel: CameraFrameKernel = (frame, render) => {
"worklet";
- if (effect == null) return; // drop frames until the pipeline is ready
render((context) => {
// 1. Your shader draws the recolored camera into the output.
const pass = context.commandEncoder.beginRenderPass({
@@ -595,72 +604,87 @@ const onFrame = useCallback(
{ view: context.outputView, loadOp: "clear", storeOp: "store" },
],
});
- pass.setPipeline(effect.pipeline);
+ pass.setPipeline(grayscale.pipeline);
pass.setBindGroup(0, context.cameraBindGroup!);
pass.draw(3);
pass.end();
// 2. The watermark still goes on top, unchanged.
- if (watermark != null) {
- const overlay = context.commandEncoder.beginRenderPass({
- colorAttachments: [
- { view: context.outputView, loadOp: "load", storeOp: "store" },
- ],
- });
- overlay.setPipeline(watermark.pipeline);
- overlay.setBindGroup(0, watermark.bindGroup);
- overlay.draw(4);
- overlay.end();
- }
+ const overlay = context.commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: context.outputView, loadOp: "load", storeOp: "store" },
+ ],
+ });
+ overlay.setPipeline(watermark.pipeline);
+ overlay.setBindGroup(0, watermark.bindGroup);
+ overlay.draw(4);
+ overlay.end();
});
- },
- [effect, watermark],
-);
-
-const { frameOutput, stream } = useVisionCameraWebGpuSource("vision-camera", {
- width: 720,
- height: 1280,
- // Provides a per-frame `cameraBindGroup` in the render context.
- cameraShaderBindings: effect?.cameraBindings,
- onFrame,
-});
+ };
+
+ const session = await createCameraFrameProcessorSession({
+ track,
+ device,
+ width: 720,
+ height: 1280,
+ // Provides a per-frame `cameraBindGroup` in the render context.
+ cameraShaderBindings: grayscale.cameraBindings,
+ frameKernel,
+ });
+
+ return { track: session.track, onClear: () => void session.dispose() };
+};
```
:::note
-Sampling the full frame stretches the camera when its aspect ratio differs from 720Γ1280. The [cropping helpers](../how-to/client/custom-sources/webgpu-effects#going-further) in the how-to cover aspect-correct sampling.
+Sampling the full frame stretches the camera when its aspect ratio differs from 720Γ1280. The [aspect ratio helpers](../how-to/client/camera-effects/webgpu-effects#going-further) in the how-to cover aspect-correct sampling.
:::
-Rebuild one last time: the receiving side shows a grayscale camera with the watermark in full color on top. The watermark pass runs after your shader, so its pixels are drawn as-is.
+Rebuild one last time and switch the effect on: the receiving side shows a grayscale camera with the watermark in full color on top. The watermark pass runs after your shader, so its pixels are drawn as-is.
## Complete example
-The final `CameraPublisher` file, top to bottom. `Room` and `App` are unchanged from the [Vision Camera tutorial](./vision-camera#complete-example):
+The final `cameraEffect.ts`, top to bottom. In `App.tsx`, the Quick Start app gets only `EffectButton` from [Step 2](#step-2-route-the-camera-through-webgpu), rendered above your own video:
-```tsx
+```ts title='cameraEffect.ts'
///
+import "react-native-webgpu";
// ---cut---
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { Image, Text } from "react-native";
import tgpu from "typegpu";
import * as d from "typegpu/data";
import { dot } from "typegpu/std";
-import { GPUShaderStage, GPUTextureUsage } from "react-native-webgpu";
-import {
- useCamera as useVisionCamera,
- useCameraPermission,
- type Frame,
-} from "react-native-vision-camera";
-import { RTCView } from "@fishjam-cloud/react-native-client";
+import { Asset } from "expo-asset";
+import { File } from "expo-file-system";
+import type { TrackMiddleware } from "@fishjam-cloud/react-native-client";
import {
- useVisionCameraWebGpuSource,
- useCameraWebGpuDevice,
+ createCameraFrameProcessorSession,
createCameraShaderBindings,
+ getCameraWebGpuDevice,
getOutputSurfaceFormat,
- type WebGpuFrameRenderFunction,
-} from "@fishjam-cloud/react-native-vision-camera-source/webgpu";
+ type CameraFrameKernel,
+} from "@fishjam-cloud/video-effects/fishjam-react-native";
// --- Watermark: a textured quad blended over the camera ---
+const watermarkAsset = Asset.fromModule(require("./assets/watermark.png"));
+
+async function loadWatermarkBitmap(): Promise {
+ // Android release builds keep bundled images as drawable resources, which `File` can't
+ // open. An asset described without the image size is copied to a local file instead.
+ const asset = new Asset({
+ name: watermarkAsset.name,
+ type: watermarkAsset.type,
+ hash: watermarkAsset.hash,
+ uri: watermarkAsset.uri,
+ });
+ await asset.downloadAsync();
+ if (!asset.localUri) {
+ throw new Error("The watermark image is not available.");
+ }
+ const bytes = await new File(asset.localUri).arrayBuffer();
+ return createImageBitmap(bytes);
+}
+
const watermarkBindingDeclarations = /* wgsl */ `
@group(0) @binding(0) var watermarkTexture: texture_2d;
@group(0) @binding(1) var watermarkSampler: sampler;
@@ -744,12 +768,10 @@ function createWatermarkPipeline(
const module = device.createShaderModule({
code:
watermarkBindingDeclarations +
- tgpu.resolve({
- externals: {
- vertexMain: makeWatermarkVertexMain({ x0, y0, x1, y1 }),
- fragmentMain: watermarkFragmentMain,
- },
- }),
+ tgpu.resolve([
+ makeWatermarkVertexMain({ x0, y0, x1, y1 }),
+ watermarkFragmentMain,
+ ]),
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
@@ -810,7 +832,9 @@ const vertexMain = tgpu.vertexFn({
});
function createGrayscaleEffect(device: GPUDevice) {
- const cameraBindings = createCameraShaderBindings(device);
+ const cameraBindings = createCameraShaderBindings(device, {
+ cameraPixelLayout: "rgb",
+ });
const fragmentMain = tgpu.fragmentFn({
in: { uv: d.location(0, d.vec2f) },
@@ -824,7 +848,7 @@ function createGrayscaleEffect(device: GPUDevice) {
const module = device.createShaderModule({
code:
cameraBindings.bindingDeclarations +
- tgpu.resolve({ externals: { vertexMain, fragmentMain } }),
+ tgpu.resolve([vertexMain, fragmentMain]),
});
const pipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
@@ -840,106 +864,57 @@ function createGrayscaleEffect(device: GPUDevice) {
return { cameraBindings, pipeline };
}
-// --- The publisher ---
+// --- The camera middleware ---
-export function CameraPublisher() {
- const { hasPermission, canRequestPermission, requestPermission } =
- useCameraPermission();
-
- useEffect(() => {
- if (!hasPermission && canRequestPermission) {
- requestPermission();
- }
- }, [hasPermission, canRequestPermission, requestPermission]);
+export const cameraEffect: TrackMiddleware = async (track) => {
+ const device = await getCameraWebGpuDevice();
+ const grayscale = createGrayscaleEffect(device);
- const { device } = useCameraWebGpuDevice();
+ const bitmap = await loadWatermarkBitmap();
+ const watermark = createWatermarkPipeline(device, bitmap, 720, 1280);
+ await device.queue.onSubmittedWorkDone();
- const effect = useMemo(
- () => (device ? createGrayscaleEffect(device) : null),
- [device],
- );
+ const frameKernel: CameraFrameKernel = (frame, render) => {
+ "worklet";
+ render((context) => {
+ const pass = context.commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: context.outputView, loadOp: "clear", storeOp: "store" },
+ ],
+ });
+ pass.setPipeline(grayscale.pipeline);
+ pass.setBindGroup(0, context.cameraBindGroup!);
+ pass.draw(3);
+ pass.end();
- const [watermark, setWatermark] = useState | null>(null);
-
- useEffect(() => {
- if (device == null) return;
- let cancelled = false;
- const load = async () => {
- const asset = Image.resolveAssetSource(require("./assets/watermark.png"));
- const response = await fetch(asset.uri);
- const bitmap = await createImageBitmap(await response.arrayBuffer());
- if (!cancelled) {
- setWatermark(createWatermarkPipeline(device, bitmap, 720, 1280));
- }
- };
- void load();
- return () => {
- cancelled = true;
- };
- }, [device]);
-
- const onFrame = useCallback(
- (frame: Frame, render: WebGpuFrameRenderFunction) => {
- "worklet";
- if (effect == null) return; // drop frames until the pipeline is ready
- render((context) => {
- const pass = context.commandEncoder.beginRenderPass({
- colorAttachments: [
- { view: context.outputView, loadOp: "clear", storeOp: "store" },
- ],
- });
- pass.setPipeline(effect.pipeline);
- pass.setBindGroup(0, context.cameraBindGroup!);
- pass.draw(3);
- pass.end();
-
- if (watermark != null) {
- const overlay = context.commandEncoder.beginRenderPass({
- colorAttachments: [
- { view: context.outputView, loadOp: "load", storeOp: "store" },
- ],
- });
- overlay.setPipeline(watermark.pipeline);
- overlay.setBindGroup(0, watermark.bindGroup);
- overlay.draw(4);
- overlay.end();
- }
+ const overlay = context.commandEncoder.beginRenderPass({
+ colorAttachments: [
+ { view: context.outputView, loadOp: "load", storeOp: "store" },
+ ],
});
- },
- [effect, watermark],
- );
+ overlay.setPipeline(watermark.pipeline);
+ overlay.setBindGroup(0, watermark.bindGroup);
+ overlay.draw(4);
+ overlay.end();
+ });
+ };
- const { frameOutput, stream } = useVisionCameraWebGpuSource("vision-camera", {
+ const session = await createCameraFrameProcessorSession({
+ track,
+ device,
width: 720,
height: 1280,
- cameraShaderBindings: effect?.cameraBindings,
- onFrame,
- });
-
- useVisionCamera({
- device: "front",
- // The hook owns the GPU queue while frames flow; wait for the upload.
- isActive: hasPermission && watermark != null,
- outputs: [frameOutput],
+ cameraShaderBindings: grayscale.cameraBindings,
+ frameKernel,
});
- if (!stream) return Starting camera⦠;
- return (
-
- );
-}
+ return { track: session.track, onClear: () => void session.dispose() };
+};
```
## Next steps
-- See the [WebGPU effects how-to](../how-to/client/custom-sources/webgpu-effects) for the rules inside `onFrame`, platform notes and cropping helpers
-- Produce frames without a camera with the [low-level frame API](../how-to/client/custom-sources/low-level-frame-api)
-- Learn [how custom sources work](../explanation/custom-sources) under the hood
-- API reference: [Vision Camera Source package](../api/vision-camera-source/index.md), [Custom Video Source package](../api/custom-video-source/index.md)
+- See the [WebGPU effects how-to](../how-to/client/camera-effects/webgpu-effects) for the rules inside the frame kernel, platform notes and aspect ratio helpers
+- Use ready-made [background blur or a background image](../how-to/client/camera-effects/background-effects.mdx) next to your own effects
+- Learn [how camera effects work](../explanation/camera-effects.mdx) under the hood
+- API reference: [Video Effects package](../api/video-effects/index.md)
diff --git a/docusaurus.config.ts b/docusaurus.config.ts
index 9300accc..356746b6 100644
--- a/docusaurus.config.ts
+++ b/docusaurus.config.ts
@@ -117,6 +117,8 @@ function buildInjectedApiItems(version: SidebarItemsGeneratorVersion) {
| NormalizedSidebarItem
)[] = [];
for (const { label, dir } of [
+ { label: "Video Effects", dir: "video-effects" },
+ { label: "React Native Worklets", dir: "react-native-worklets" },
{ label: "Vision Camera Source", dir: "vision-camera-source" },
{ label: "Custom Video Source", dir: "custom-video-source" },
]) {
@@ -490,6 +492,32 @@ const config: Config = {
...typedocConfig,
},
],
+ [
+ "docusaurus-plugin-typedoc",
+ {
+ id: "video-effects-api",
+ out: "docs/api/video-effects",
+ entryPoints: [
+ "./packages/video-effects/src/index.ts",
+ "./packages/video-effects/src/background-blur.ts",
+ "./packages/video-effects/src/background-image.ts",
+ "./packages/video-effects/src/segmentation/typegpu/index.ts",
+ "./packages/video-effects/src/fishjam-react-native.ts",
+ ],
+ tsconfig: "./packages/video-effects/tsconfig.json",
+ ...typedocConfig,
+ },
+ ],
+ [
+ "docusaurus-plugin-typedoc",
+ {
+ id: "react-native-worklets-api",
+ out: "docs/api/react-native-worklets",
+ entryPoints: ["./packages/react-native-worklets/src/index.ts"],
+ tsconfig: "./packages/react-native-worklets/tsconfig.json",
+ ...typedocConfig,
+ },
+ ],
[
"docusaurus-plugin-typedoc",
{
diff --git a/package.json b/package.json
index 1a4c0a91..b74b3501 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,8 @@
"@fishjam-cloud/react-native-custom-video-source": "link:./packages/web-client-sdk/packages/react-native-custom-video-source",
"@fishjam-cloud/react-native-vision-camera-source": "link:./packages/web-client-sdk/packages/react-native-vision-camera-source",
"@fishjam-cloud/react-native-webrtc": "link:./packages/web-client-sdk/packages/react-native-webrtc",
+ "@fishjam-cloud/react-native-worklets": "link:./packages/react-native-worklets",
+ "@fishjam-cloud/video-effects": "link:./packages/video-effects",
"@mdx-js/react": "^3.1.0",
"@scalar/docusaurus": "^0.8.5",
"@shikijs/rehype": "^3.6.0",
@@ -58,7 +60,7 @@
"react-native": "^0.80.0",
"react-native-vision-camera": "5.0.10",
"shiki": "^3.6.0",
- "typegpu": "^0.11.8"
+ "typegpu": "0.12.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.0",
@@ -71,8 +73,11 @@
"cspell": "^9.1.2",
"docusaurus-plugin-llms": "^0.3.0",
"docusaurus-plugin-typedoc": "1.4.0",
+ "expo-asset": "~57.0.17",
+ "expo-file-system": "~57.0.7",
"prettier": "^3.5.3",
"react-native-webgpu": "~0.5.15",
+ "react-native-worklets": "0.12.1",
"typedoc": "0.28.5",
"typedoc-plugin-markdown": "4.7.0",
"typescript": "~5.8.2"
diff --git a/packages/react-native-worklets b/packages/react-native-worklets
new file mode 160000
index 00000000..81249ddd
--- /dev/null
+++ b/packages/react-native-worklets
@@ -0,0 +1 @@
+Subproject commit 81249ddd475aabb74ee4bf917931107b318a4879
diff --git a/packages/video-effects b/packages/video-effects
new file mode 160000
index 00000000..105f9d09
--- /dev/null
+++ b/packages/video-effects
@@ -0,0 +1 @@
+Subproject commit 105f9d09c97fccd46cd169db56e58aa052d75531
diff --git a/packages/web-client-sdk b/packages/web-client-sdk
index be6bc1b6..fff4e570 160000
--- a/packages/web-client-sdk
+++ b/packages/web-client-sdk
@@ -1 +1 @@
-Subproject commit be6bc1b673827e28bf8df7bed88de7c874f96db7
+Subproject commit fff4e5704db55460ea686103f3be3212df29aed8
diff --git a/redirects/index.ts b/redirects/index.ts
index 90d133d7..7f72ced0 100644
--- a/redirects/index.ts
+++ b/redirects/index.ts
@@ -8,6 +8,29 @@ interface RedirectGroup {
}
const redirectGroups: RedirectGroup[] = [
+ {
+ since: "0.30.1",
+ description:
+ "Camera effects category added; VisionCamera pages moved to Integrations",
+ rules: [
+ {
+ from: "/how-to/client/stream-middleware",
+ to: "/how-to/client/camera-effects/stream-middleware",
+ },
+ {
+ from: "/how-to/client/custom-sources/webgpu-effects",
+ to: "/how-to/client/camera-effects/webgpu-effects",
+ },
+ {
+ from: "/how-to/client/custom-sources/vision-camera",
+ to: "/integrations/vision-camera/vision-camera-source",
+ },
+ {
+ from: "/tutorials/vision-camera",
+ to: "/integrations/vision-camera/stream-vision-camera",
+ },
+ ],
+ },
{
since: "0.28.0",
description:
diff --git a/scripts/prepare.sh b/scripts/prepare.sh
index 659ebc27..77c5a053 100755
--- a/scripts/prepare.sh
+++ b/scripts/prepare.sh
@@ -58,6 +58,14 @@ cd $ROOTDIR
cd packages/web-client-sdk/packages/react-native-vision-camera-source/
yarn && yarn build
+cd $ROOTDIR
+cd packages/react-native-worklets/
+yarn && yarn build
+
+cd $ROOTDIR
+cd packages/video-effects/
+yarn && yarn build
+
cd $ROOTDIR
cd packages/js-server-sdk/
yarn && yarn build
diff --git a/spelling.txt b/spelling.txt
index e0f84a14..006057c6 100644
--- a/spelling.txt
+++ b/spelling.txt
@@ -134,3 +134,4 @@ Gradle
entitlement
entitlements
rtmp
+ssgbin
diff --git a/yarn.lock b/yarn.lock
index 7aba4c52..9e3a4a55 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -266,6 +266,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/code-frame@npm:^7.20.0, @babel/code-frame@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/code-frame@npm:7.29.7"
+ dependencies:
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ js-tokens: "npm:^4.0.0"
+ picocolors: "npm:^1.1.1"
+ checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069
+ languageName: node
+ linkType: hard
+
"@babel/compat-data@npm:^7.27.2, @babel/compat-data@npm:^7.27.7, @babel/compat-data@npm:^7.28.0":
version: 7.28.0
resolution: "@babel/compat-data@npm:7.28.0"
@@ -273,6 +284,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/compat-data@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/compat-data@npm:7.29.7"
+ checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e
+ languageName: node
+ linkType: hard
+
"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.25.2, @babel/core@npm:^7.25.9":
version: 7.28.0
resolution: "@babel/core@npm:7.28.0"
@@ -309,6 +327,19 @@ __metadata:
languageName: node
linkType: hard
+"@babel/generator@npm:^7.27.1, @babel/generator@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/generator@npm:7.29.8"
+ dependencies:
+ "@babel/parser": "npm:^7.29.8"
+ "@babel/types": "npm:^7.29.8"
+ "@jridgewell/gen-mapping": "npm:^0.3.12"
+ "@jridgewell/trace-mapping": "npm:^0.3.28"
+ jsesc: "npm:^3.0.2"
+ checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441
+ languageName: node
+ linkType: hard
+
"@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3":
version: 7.27.3
resolution: "@babel/helper-annotate-as-pure@npm:7.27.3"
@@ -318,6 +349,15 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-annotate-as-pure@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-annotate-as-pure@npm:7.29.7"
+ dependencies:
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/c56536b52d17632d89d49db2063ed6102f0e3bbadf6a0ccb74e6599d6a77173b644c7fe8c3ef17c7a162709d55b75ee5145ef6db917d16ba7f375fbffcf2e942
+ languageName: node
+ linkType: hard
+
"@babel/helper-compilation-targets@npm:^7.27.1, @babel/helper-compilation-targets@npm:^7.27.2":
version: 7.27.2
resolution: "@babel/helper-compilation-targets@npm:7.27.2"
@@ -331,6 +371,19 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-compilation-targets@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-compilation-targets@npm:7.29.7"
+ dependencies:
+ "@babel/compat-data": "npm:^7.29.7"
+ "@babel/helper-validator-option": "npm:^7.29.7"
+ browserslist: "npm:^4.24.0"
+ lru-cache: "npm:^5.1.1"
+ semver: "npm:^6.3.1"
+ checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b
+ languageName: node
+ linkType: hard
+
"@babel/helper-create-class-features-plugin@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-create-class-features-plugin@npm:7.27.1"
@@ -348,6 +401,23 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-create-class-features-plugin@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-create-class-features-plugin@npm:7.29.7"
+ dependencies:
+ "@babel/helper-annotate-as-pure": "npm:^7.29.7"
+ "@babel/helper-member-expression-to-functions": "npm:^7.29.7"
+ "@babel/helper-optimise-call-expression": "npm:^7.29.7"
+ "@babel/helper-replace-supers": "npm:^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ semver: "npm:^6.3.1"
+ peerDependencies:
+ "@babel/core": ^7.0.0
+ checksum: 10c0/75f34905b5e708b473f1e9b33e07b2fcc8f4c60676df8bc74541bb91c77f387c32a948dd04d5071e469ba454d72d0a872e3ace40fbb1d1e7aaa8569efcf09ed4
+ languageName: node
+ linkType: hard
+
"@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-create-regexp-features-plugin@npm:7.27.1"
@@ -383,6 +453,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-globals@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-globals@npm:7.29.7"
+ checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a
+ languageName: node
+ linkType: hard
+
"@babel/helper-member-expression-to-functions@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-member-expression-to-functions@npm:7.27.1"
@@ -393,6 +470,16 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-member-expression-to-functions@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-member-expression-to-functions@npm:7.29.7"
+ dependencies:
+ "@babel/traverse": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/eef7940ce0797208854a5af1049a98fee9abbffb5c619640c69ff5a555f8e3552295bb18756490b02bc6af7df8c1babcb83f12203aac2deb9dfecfc78846e12d
+ languageName: node
+ linkType: hard
+
"@babel/helper-module-imports@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-module-imports@npm:7.27.1"
@@ -403,6 +490,16 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-module-imports@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-module-imports@npm:7.29.7"
+ dependencies:
+ "@babel/traverse": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d
+ languageName: node
+ linkType: hard
+
"@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.27.3":
version: 7.27.3
resolution: "@babel/helper-module-transforms@npm:7.27.3"
@@ -416,6 +513,19 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-module-transforms@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-module-transforms@npm:7.29.7"
+ dependencies:
+ "@babel/helper-module-imports": "npm:^7.29.7"
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0
+ checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1
+ languageName: node
+ linkType: hard
+
"@babel/helper-optimise-call-expression@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-optimise-call-expression@npm:7.27.1"
@@ -425,6 +535,15 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-optimise-call-expression@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-optimise-call-expression@npm:7.29.7"
+ dependencies:
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/fd0244b9bfbb487db02d59aa2703c6991d654ea5f3f39d912682842bdca2e87b5ae8643b0ce8069bf5fbee39d1aa9db7abefeb5e6ba1aa650dca12777cf5b7e2
+ languageName: node
+ linkType: hard
+
"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.8.0":
version: 7.27.1
resolution: "@babel/helper-plugin-utils@npm:7.27.1"
@@ -432,6 +551,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-plugin-utils@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-plugin-utils@npm:7.29.7"
+ checksum: 10c0/380477a06133274a2759f9355929cb60a95e8b8fee624a1ae1fa349e1d1645b89daca456f72833f6d1062bffa12ee4271c5bf0cc5a61c0166cdc24c7591e2408
+ languageName: node
+ linkType: hard
+
"@babel/helper-remap-async-to-generator@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1"
@@ -458,6 +584,19 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-replace-supers@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-replace-supers@npm:7.29.7"
+ dependencies:
+ "@babel/helper-member-expression-to-functions": "npm:^7.29.7"
+ "@babel/helper-optimise-call-expression": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0
+ checksum: 10c0/1c7ae37797f226e965ab85f6affa53d25a10c169c604a4daeb36f9df09e673471e6522f631c13761cf9fbafeca2ea14c241dea8d723a51039d561beb01d86ac4
+ languageName: node
+ linkType: hard
+
"@babel/helper-skip-transparent-expression-wrappers@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.27.1"
@@ -468,6 +607,16 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-skip-transparent-expression-wrappers@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.29.7"
+ dependencies:
+ "@babel/traverse": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/8c59493621487fc491f27adfc200af82a6aca3b9a5511e4e6050f8716593b4b243472cb56c8d2016e828b7ae12d605a819205aa8600ca08ee291dcd58d65c832
+ languageName: node
+ linkType: hard
+
"@babel/helper-string-parser@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-string-parser@npm:7.27.1"
@@ -475,6 +624,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-string-parser@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-string-parser@npm:7.29.7"
+ checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c
+ languageName: node
+ linkType: hard
+
"@babel/helper-validator-identifier@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-validator-identifier@npm:7.27.1"
@@ -482,6 +638,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-validator-identifier@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-validator-identifier@npm:7.29.7"
+ checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b
+ languageName: node
+ linkType: hard
+
"@babel/helper-validator-option@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-validator-option@npm:7.27.1"
@@ -489,6 +652,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/helper-validator-option@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/helper-validator-option@npm:7.29.7"
+ checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc
+ languageName: node
+ linkType: hard
+
"@babel/helper-wrap-function@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/helper-wrap-function@npm:7.27.1"
@@ -521,6 +691,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/parser@npm:7.29.8"
+ dependencies:
+ "@babel/types": "npm:^7.29.8"
+ bin:
+ parser: ./bin/babel-parser.js
+ checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28
+ languageName: node
+ linkType: hard
+
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.27.1"
@@ -699,6 +880,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-syntax-jsx@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/plugin-syntax-jsx@npm:7.29.7"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/1736000de183538ba8eef34520105508860e48b0c763254ba9158af5e814ed8bbceeedbb4281fbda33de787ae5b3870e92f60c6ae7131e7d322e451d57387896
+ languageName: node
+ linkType: hard
+
"@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4":
version: 7.10.4
resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4"
@@ -798,6 +990,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-syntax-typescript@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/plugin-syntax-typescript@npm:7.29.7"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/c49883b0327e8683b770dc823205af5c697da216e590dcf5bf53f3f031e7e381de450b164f8f99853f0837a3de5cb793298e2be6697a0f6e452bb9dd34b5165e
+ languageName: node
+ linkType: hard
+
"@babel/plugin-syntax-unicode-sets-regex@npm:^7.18.6":
version: 7.18.6
resolution: "@babel/plugin-syntax-unicode-sets-regex@npm:7.18.6"
@@ -881,6 +1084,18 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-class-properties@npm:^7.28.6":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-class-properties@npm:7.29.7"
+ dependencies:
+ "@babel/helper-create-class-features-plugin": "npm:^7.29.7"
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/c370700423439aa9f0c1f8c4b97f2ef7c2dc46a1b04ec3b10e83e6bae5e4e2159f56d8e4376c9d669b3cf827650cc3740170a36e3924e3e9970d27fd85f4e48a
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-class-static-block@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-transform-class-static-block@npm:7.27.1"
@@ -909,6 +1124,22 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-classes@npm:^7.28.6":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-classes@npm:7.29.7"
+ dependencies:
+ "@babel/helper-annotate-as-pure": "npm:^7.29.7"
+ "@babel/helper-compilation-targets": "npm:^7.29.7"
+ "@babel/helper-globals": "npm:^7.29.7"
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ "@babel/helper-replace-supers": "npm:^7.29.7"
+ "@babel/traverse": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/53a55bc5348d82ca744dbfcfedf33ab79877e609a5308f43976de8c240bd09cee195a535bf54a01b28d7080eebe759735b1c6cf39f252eef469eefc1d838d2a2
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-computed-properties@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-transform-computed-properties@npm:7.27.1"
@@ -1094,6 +1325,18 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-modules-commonjs@npm:^7.24.8, @babel/plugin-transform-modules-commonjs@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-modules-commonjs@npm:7.29.7"
+ dependencies:
+ "@babel/helper-module-transforms": "npm:^7.29.7"
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/9791cb524438b2a8ba6cb8715788fa1e202fbecd4e76b3ccab0af0819fd69212b40ae30d72ac377012f7149889f7792ed8bf91e97bbe9113ca9641f8ad3bf332
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-modules-commonjs@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-transform-modules-commonjs@npm:7.27.1"
@@ -1166,6 +1409,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.28.6":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.29.7"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/b0c186fe38bc66830e1be76f06fabbae8a655d3896a841ba5ffa12d6c40bb9c8a6ecd38a7e2196034b1d7470653109b1ceac1ef5e46a5fdc9291d14afa56e0d0
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-numeric-separator@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-transform-numeric-separator@npm:7.27.1"
@@ -1227,6 +1481,18 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-optional-chaining@npm:^7.28.6":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-optional-chaining@npm:7.29.7"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/71feacf9a7083030f4c69bf4e91db75f2fceae28e58c58b63db040006d908e15bc1e85a464f24ad659f17702e91a64eabbff9f1dd555ba33d78bb1af8a1d697a
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-parameters@npm:^7.27.7":
version: 7.27.7
resolution: "@babel/plugin-transform-parameters@npm:7.27.7"
@@ -1455,6 +1721,21 @@ __metadata:
languageName: node
linkType: hard
+"@babel/plugin-transform-typescript@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/plugin-transform-typescript@npm:7.29.7"
+ dependencies:
+ "@babel/helper-annotate-as-pure": "npm:^7.29.7"
+ "@babel/helper-create-class-features-plugin": "npm:^7.29.7"
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.29.7"
+ "@babel/plugin-syntax-typescript": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/8bf6a89c6827af6f11d4b189f1f97a64b8d754cc4caa5cbae16a6a8b113294d7f310dd40efd82e87ebcff2a1c683584b872d6d8960abe88d48f441d42f94c4d1
+ languageName: node
+ linkType: hard
+
"@babel/plugin-transform-unicode-escapes@npm:^7.27.1":
version: 7.27.1
resolution: "@babel/plugin-transform-unicode-escapes@npm:7.27.1"
@@ -1626,6 +1907,21 @@ __metadata:
languageName: node
linkType: hard
+"@babel/preset-typescript@npm:^7.28.5":
+ version: 7.29.7
+ resolution: "@babel/preset-typescript@npm:7.29.7"
+ dependencies:
+ "@babel/helper-plugin-utils": "npm:^7.29.7"
+ "@babel/helper-validator-option": "npm:^7.29.7"
+ "@babel/plugin-syntax-jsx": "npm:^7.29.7"
+ "@babel/plugin-transform-modules-commonjs": "npm:^7.29.7"
+ "@babel/plugin-transform-typescript": "npm:^7.29.7"
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+ checksum: 10c0/40746a23a7ab46c0beb1c02d69883d9ffbe3043685cb3ae5363644391376b9261189fdad317191349e322c2c9bc550b031daa42470a1f8987362e4c56e492194
+ languageName: node
+ linkType: hard
+
"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.3, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.9":
version: 7.27.6
resolution: "@babel/runtime@npm:7.27.6"
@@ -1644,6 +1940,17 @@ __metadata:
languageName: node
linkType: hard
+"@babel/template@npm:^7.29.7":
+ version: 7.29.7
+ resolution: "@babel/template@npm:7.29.7"
+ dependencies:
+ "@babel/code-frame": "npm:^7.29.7"
+ "@babel/parser": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.7"
+ checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58
+ languageName: node
+ linkType: hard
+
"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3, @babel/traverse@npm:^7.25.3, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3, @babel/traverse@npm:^7.28.0":
version: 7.28.0
resolution: "@babel/traverse@npm:7.28.0"
@@ -1659,6 +1966,21 @@ __metadata:
languageName: node
linkType: hard
+"@babel/traverse@npm:^7.29.7":
+ version: 7.29.8
+ resolution: "@babel/traverse@npm:7.29.8"
+ dependencies:
+ "@babel/code-frame": "npm:^7.29.7"
+ "@babel/generator": "npm:^7.29.8"
+ "@babel/helper-globals": "npm:^7.29.7"
+ "@babel/parser": "npm:^7.29.8"
+ "@babel/template": "npm:^7.29.7"
+ "@babel/types": "npm:^7.29.8"
+ debug: "npm:^4.3.1"
+ checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923
+ languageName: node
+ linkType: hard
+
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.25.2, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4":
version: 7.28.0
resolution: "@babel/types@npm:7.28.0"
@@ -1669,6 +1991,16 @@ __metadata:
languageName: node
linkType: hard
+"@babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8":
+ version: 7.29.8
+ resolution: "@babel/types@npm:7.29.8"
+ dependencies:
+ "@babel/helper-string-parser": "npm:^7.29.7"
+ "@babel/helper-validator-identifier": "npm:^7.29.7"
+ checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6
+ languageName: node
+ linkType: hard
+
"@braintree/sanitize-url@npm:^7.0.4":
version: 7.1.1
resolution: "@braintree/sanitize-url@npm:7.1.1"
@@ -3656,6 +3988,57 @@ __metadata:
languageName: node
linkType: hard
+"@expo/env@npm:~2.4.3":
+ version: 2.4.3
+ resolution: "@expo/env@npm:2.4.3"
+ dependencies:
+ chalk: "npm:^4.0.0"
+ debug: "npm:^4.3.4"
+ getenv: "npm:^2.0.0"
+ checksum: 10c0/36eb2dacda0765eca69b3df7aaa3e5a74fb05561f5e223ca19a376a70ed18934edb1782345299fb9b026f0d1e4773f047835f1c16035151c1a76a1e373b29412
+ languageName: node
+ linkType: hard
+
+"@expo/image-utils@npm:^0.11.5":
+ version: 0.11.5
+ resolution: "@expo/image-utils@npm:0.11.5"
+ dependencies:
+ "@expo/require-utils": "npm:^57.0.5"
+ "@expo/spawn-async": "npm:^1.8.0"
+ chalk: "npm:^4.0.0"
+ getenv: "npm:^2.0.0"
+ jimp-compact: "npm:0.16.1"
+ parse-png: "npm:^2.1.0"
+ semver: "npm:^7.6.0"
+ checksum: 10c0/bca05d61164c88eafcbb01e82eea92679b9fcbc435ad50f0b8f7b0c74259ea50a7e7535d5403ea747be241c9560b04be66df8f3e0f926a6da1e5a1954a41a45d
+ languageName: node
+ linkType: hard
+
+"@expo/require-utils@npm:^57.0.5":
+ version: 57.0.5
+ resolution: "@expo/require-utils@npm:57.0.5"
+ dependencies:
+ "@babel/code-frame": "npm:^7.20.0"
+ "@babel/core": "npm:^7.25.2"
+ "@babel/plugin-transform-modules-commonjs": "npm:^7.24.8"
+ peerDependencies:
+ typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 10c0/42841b76d6540098b664bee633e6ef9825ac84041e7c91045bd15a84d6aed4f2ad1f5027da263755a1e2dc2190283e7b7d6c4fb9ec3335c7a87016606b443754
+ languageName: node
+ linkType: hard
+
+"@expo/spawn-async@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "@expo/spawn-async@npm:1.8.0"
+ dependencies:
+ cross-spawn: "npm:^7.0.6"
+ checksum: 10c0/08d3c63f9cc097ce9c8cf6850ca482fd7999a6fddc4cb38a3a9915a1662cb674fe7353de2eb3c693728542bf57db732ae433e82b2d698be141d07cea3092ebf3
+ languageName: node
+ linkType: hard
+
"@fastify/ajv-compiler@npm:^4.0.0":
version: 4.0.2
resolution: "@fastify/ajv-compiler@npm:4.0.2"
@@ -3761,6 +4144,18 @@ __metadata:
languageName: node
linkType: soft
+"@fishjam-cloud/react-native-worklets@link:./packages/react-native-worklets::locator=fishjam-docs%40workspace%3A.":
+ version: 0.0.0-use.local
+ resolution: "@fishjam-cloud/react-native-worklets@link:./packages/react-native-worklets::locator=fishjam-docs%40workspace%3A."
+ languageName: node
+ linkType: soft
+
+"@fishjam-cloud/video-effects@link:./packages/video-effects::locator=fishjam-docs%40workspace%3A.":
+ version: 0.0.0-use.local
+ resolution: "@fishjam-cloud/video-effects@link:./packages/video-effects::locator=fishjam-docs%40workspace%3A."
+ languageName: node
+ linkType: soft
+
"@gerrit0/mini-shiki@npm:^3.2.2":
version: 3.7.0
resolution: "@gerrit0/mini-shiki@npm:3.7.0"
@@ -11513,6 +11908,20 @@ __metadata:
languageName: node
linkType: hard
+"expo-asset@npm:~57.0.17":
+ version: 57.0.17
+ resolution: "expo-asset@npm:57.0.17"
+ dependencies:
+ "@expo/image-utils": "npm:^0.11.5"
+ expo-constants: "npm:~57.0.18"
+ peerDependencies:
+ expo: "*"
+ react: "*"
+ react-native: "*"
+ checksum: 10c0/790623f1ac8a8ddab46d835c04bed43223e2235acd6a42f111f91527b723b3f9905afdc7c0e77ac71b14094ae81fee3958dd6a9199282b086c7601b8a3967c86
+ languageName: node
+ linkType: hard
+
"expo-camera@npm:^16.1.8":
version: 16.1.10
resolution: "expo-camera@npm:16.1.10"
@@ -11530,6 +11939,28 @@ __metadata:
languageName: node
linkType: hard
+"expo-constants@npm:~57.0.18":
+ version: 57.0.18
+ resolution: "expo-constants@npm:57.0.18"
+ dependencies:
+ "@expo/env": "npm:~2.4.3"
+ peerDependencies:
+ expo: "*"
+ react-native: "*"
+ checksum: 10c0/80020a95c36021a5ef084e0a5f9611d63b8375c86b767ae475ec074ab8f5f71a0dc0014fffaff3f7c7e7995861e316f6a2ce5580cbfc89767077ca4814ebc92f
+ languageName: node
+ linkType: hard
+
+"expo-file-system@npm:~57.0.7":
+ version: 57.0.7
+ resolution: "expo-file-system@npm:57.0.7"
+ peerDependencies:
+ expo: "*"
+ react-native: "*"
+ checksum: 10c0/ca02903e7b918f2b73f9f009d13e2f3f2eda46b04d479af770385c463631feb843f6a742c34a75ad7e892862dfb4b4311fa0520d7599a9f01e478014b105d3b0
+ languageName: node
+ linkType: hard
+
"exponential-backoff@npm:^3.1.1":
version: 3.1.2
resolution: "exponential-backoff@npm:3.1.2"
@@ -11944,6 +12375,8 @@ __metadata:
"@fishjam-cloud/react-native-custom-video-source": "link:./packages/web-client-sdk/packages/react-native-custom-video-source"
"@fishjam-cloud/react-native-vision-camera-source": "link:./packages/web-client-sdk/packages/react-native-vision-camera-source"
"@fishjam-cloud/react-native-webrtc": "link:./packages/web-client-sdk/packages/react-native-webrtc"
+ "@fishjam-cloud/react-native-worklets": "link:./packages/react-native-worklets"
+ "@fishjam-cloud/video-effects": "link:./packages/video-effects"
"@google/genai": "npm:^1.35.0"
"@mdx-js/react": "npm:^3.1.0"
"@scalar/docusaurus": "npm:^0.8.5"
@@ -11960,7 +12393,9 @@ __metadata:
cspell: "npm:^9.1.2"
docusaurus-plugin-llms: "npm:^0.3.0"
docusaurus-plugin-typedoc: "npm:1.4.0"
+ expo-asset: "npm:~57.0.17"
expo-camera: "npm:^16.1.8"
+ expo-file-system: "npm:~57.0.7"
fastify: "npm:^5.4.0"
mock-import: "npm:^4.2.1"
prettier: "npm:^3.5.3"
@@ -11970,10 +12405,11 @@ __metadata:
react-native: "npm:^0.80.0"
react-native-vision-camera: "npm:5.0.10"
react-native-webgpu: "npm:~0.5.15"
+ react-native-worklets: "npm:0.12.1"
shiki: "npm:^3.6.0"
typedoc: "npm:0.28.5"
typedoc-plugin-markdown: "npm:4.7.0"
- typegpu: "npm:^0.11.8"
+ typegpu: "npm:0.12.4"
typescript: "npm:~5.8.2"
languageName: unknown
linkType: soft
@@ -12260,6 +12696,13 @@ __metadata:
languageName: node
linkType: hard
+"getenv@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "getenv@npm:2.0.0"
+ checksum: 10c0/397ff641dd70cd78e414430258651e9a2228d3c5553a8cf15ae7840f75d3f10dfcb83f668f84829e84ea665b0fce2f08a9eddda3c9dcd7faa2d3da1c182c1854
+ languageName: node
+ linkType: hard
+
"github-slugger@npm:^1.5.0":
version: 1.5.0
resolution: "github-slugger@npm:1.5.0"
@@ -13778,6 +14221,13 @@ __metadata:
languageName: node
linkType: hard
+"jimp-compact@npm:0.16.1":
+ version: 0.16.1
+ resolution: "jimp-compact@npm:0.16.1"
+ checksum: 10c0/2d73bb927d840ce6dc093d089d770eddbb81472635ced7cad1d7c4545d8734aecf5bd3dedf7178a6cfab4d06c9d6cbbf59e5cb274ed99ca11cd4835a6374f16c
+ languageName: node
+ linkType: hard
+
"jiti@npm:^1.20.0":
version: 1.21.7
resolution: "jiti@npm:1.21.7"
@@ -16540,6 +16990,15 @@ __metadata:
languageName: node
linkType: hard
+"parse-png@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "parse-png@npm:2.1.0"
+ dependencies:
+ pngjs: "npm:^3.3.0"
+ checksum: 10c0/5157a8bbb976ae1ca990fc53c7014d42aac0967cb30e2daf36c3fef1876c8db0d551e695400c904f33c5c5add76a572c65b5044721d62417d8cc7abe4c4ffa41
+ languageName: node
+ linkType: hard
+
"parse5-htmlparser2-tree-adapter@npm:^7.0.0":
version: 7.1.0
resolution: "parse5-htmlparser2-tree-adapter@npm:7.1.0"
@@ -16799,6 +17258,13 @@ __metadata:
languageName: node
linkType: hard
+"pngjs@npm:^3.3.0":
+ version: 3.4.0
+ resolution: "pngjs@npm:3.4.0"
+ checksum: 10c0/88ee73e2ad3f736e0b2573722309eb80bd2aa28916f0862379b4fd0f904751b4f61bb6bd1ecd7d4242d331f2b5c28c13309dd4b7d89a9b78306e35122fdc5011
+ languageName: node
+ linkType: hard
+
"points-on-curve@npm:0.2.0, points-on-curve@npm:^0.2.0":
version: 0.2.0
resolution: "points-on-curve@npm:0.2.0"
@@ -18233,6 +18699,33 @@ __metadata:
languageName: node
linkType: hard
+"react-native-worklets@npm:0.12.1":
+ version: 0.12.1
+ resolution: "react-native-worklets@npm:0.12.1"
+ dependencies:
+ "@babel/generator": "npm:^7.27.1"
+ "@babel/plugin-transform-arrow-functions": "npm:^7.27.1"
+ "@babel/plugin-transform-class-properties": "npm:^7.28.6"
+ "@babel/plugin-transform-classes": "npm:^7.28.6"
+ "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.28.6"
+ "@babel/plugin-transform-optional-chaining": "npm:^7.28.6"
+ "@babel/plugin-transform-shorthand-properties": "npm:^7.27.1"
+ "@babel/plugin-transform-template-literals": "npm:^7.27.1"
+ "@babel/plugin-transform-unicode-regex": "npm:^7.27.1"
+ "@babel/preset-typescript": "npm:^7.28.5"
+ "@babel/traverse": "npm:^7.27.1"
+ "@babel/types": "npm:^7.27.1"
+ convert-source-map: "npm:^2.0.0"
+ semver: "npm:^7.7.4"
+ peerDependencies:
+ "@babel/core": "*"
+ "@react-native/metro-config": "*"
+ react: "*"
+ react-native: 0.83 - 0.87
+ checksum: 10c0/86b9043ed2ceacd3dada3f94d62393a5bb54c7a26877e5bb54171cd4455b34f4148009a6b41bf53be70f265444bd7fff287f4d0fd8599e27d476c10e6cd7923f
+ languageName: node
+ linkType: hard
+
"react-native@npm:^0.80.0":
version: 0.80.1
resolution: "react-native@npm:0.80.1"
@@ -19350,6 +19843,15 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:^7.7.4":
+ version: 7.8.5
+ resolution: "semver@npm:7.8.5"
+ bin:
+ semver: bin/semver.js
+ checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c
+ languageName: node
+ linkType: hard
+
"send@npm:0.19.0":
version: 0.19.0
resolution: "send@npm:0.19.0"
@@ -20352,10 +20854,10 @@ __metadata:
languageName: node
linkType: hard
-"tinyest@npm:~0.3.2":
- version: 0.3.2
- resolution: "tinyest@npm:0.3.2"
- checksum: 10c0/05092ca73224eda2374ad0475522bf9946ab07854d2f6f09e48ebaf73f77a14e32e2be42f73191cef71320036438f8c8986b9a6844b99991d194d11c99f7df8b
+"tinyest@npm:~0.3.3":
+ version: 0.3.3
+ resolution: "tinyest@npm:0.3.3"
+ checksum: 10c0/4a80563d5fe4fdd7ce93dda1ae64f9f1f6822c3696e46f2abd972a610ffb1e0285ff39796778aaacc2bba148e4ec103df937fdf77f39fee3e9e006a9211c4eea
languageName: node
linkType: hard
@@ -20618,16 +21120,16 @@ __metadata:
languageName: node
linkType: hard
-"typegpu@npm:^0.11.8":
- version: 0.11.9
- resolution: "typegpu@npm:0.11.9"
+"typegpu@npm:0.12.4":
+ version: 0.12.4
+ resolution: "typegpu@npm:0.12.4"
dependencies:
- tinyest: "npm:~0.3.2"
+ tinyest: "npm:~0.3.3"
tsover-runtime: "npm:^0.0.7"
typed-binary: "npm:^4.3.3"
bin:
typegpu: ./bin.mjs
- checksum: 10c0/44ba12e1c9af79849d4714390bef2b5521710749940bd2da4dd32d696869e21fc0c43f80beddad315c5605bfbb8910cd3d1a3a42efb26b2d8572265d8d1d5db7
+ checksum: 10c0/8269d56d01b7854319a6a54acc3091fb36da539a1bf2c255afd520061a33ad4bc4598daaf80a1047e6bdda5a69b2fcadba924db304ccd3c9b6d8090a42d76508
languageName: node
linkType: hard