From 06990a08d0d010243d14a429a83d671852b12ae1 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 23 Apr 2026 15:53:27 +0200 Subject: [PATCH 01/15] fix(rn): perf and stability fixes for video-filters --- .../src/contexts/BackgroundFilters.tsx | 37 ++++++++++ .../common/VideoFrameWithBitmapFilter.kt | 6 +- .../common/YuvFrame.kt | 8 +- .../factories/VirtualBackgroundFactory.kt | 55 +++++++------- .../BlurBackgroundVideoFrameProcessor.swift | 15 +++- .../ImageBackgroundVideoFrameProcessor.swift | 74 ++++++++++++------- .../Utils/VideoFilters.swift | 6 +- 7 files changed, 136 insertions(+), 65 deletions(-) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index 2464404543..86ac0333b5 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -2,6 +2,7 @@ import React, { type PropsWithChildren, useCallback, useContext, + useEffect, useMemo, useRef, useState, @@ -76,6 +77,10 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { const isBackgroundBlurRegisteredRef = useRef(false); const isVideoBlurRegisteredRef = useRef(false); const registeredImageFiltersSetRef = useRef(new Set()); + // Holds the exact native filter name so we can reapply on track replacement + // (camera flip, enable-after-disable). State alone can't distinguish + // `BackgroundBlur*` from `Blur*` — both use `{ blur: intensity }`. + const lastAppliedFilterNameRef = useRef(null); const [currentBackgroundFilter, setCurrentBackgroundFilter] = useState(); @@ -96,6 +101,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { filterName = 'BackgroundBlurLight'; } call?.tracer.trace('backgroundFilters.apply', filterName); + lastAppliedFilterNameRef.current = filterName; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -122,6 +128,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { filterName = 'BlurLight'; } call?.tracer.trace('videoFilters.apply', filterName); + lastAppliedFilterNameRef.current = filterName; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -146,6 +153,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { } const filterName = `VirtualBackground-${imageUri}`; call?.tracer.trace('backgroundFilters.apply', filterName); + lastAppliedFilterNameRef.current = filterName; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -161,6 +169,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { return; } call?.tracer.trace('backgroundFilters.disableAll', null); + lastAppliedFilterNameRef.current = null; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -169,6 +178,34 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { setCurrentBackgroundFilter(undefined); }, [call]); + // Reapply the active filter on track replacement (camera flip, enable-after-disable) + // and release native filter state when the provider unmounts or the call changes. + useEffect(() => { + if (!call || !isSupported) return; + const registeredImageFiltersSet = registeredImageFiltersSetRef.current; + const subscription = call.camera.state.mediaStream$.subscribe(() => { + const name = lastAppliedFilterNameRef.current; + if (!name) return; + (call.camera.state.mediaStream as MediaStream | undefined) + ?.getVideoTracks() + .forEach((track) => { + track._setVideoEffect(name); + }); + }); + return () => { + subscription.unsubscribe(); + (call.camera.state.mediaStream as MediaStream | undefined) + ?.getVideoTracks() + .forEach((track) => { + track._setVideoEffect(null); + }); + lastAppliedFilterNameRef.current = null; + isBackgroundBlurRegisteredRef.current = false; + isVideoBlurRegisteredRef.current = false; + registeredImageFiltersSet.clear(); + }; + }, [call]); + const value = useMemo( () => ({ currentBackgroundFilter, diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt index 6025ce0b33..fce062c265 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt @@ -18,6 +18,7 @@ import org.webrtc.YuvConverter class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVideoFilter) : VideoFrameProcessor { private val yuvConverter = YuvConverter() + private val yuvFrame = YuvFrame() private var inputWidth = 0 private var inputHeight = 0 private var inputBuffer: VideoFrame.TextureBuffer? = null @@ -35,7 +36,7 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid override fun process(frame: VideoFrame, surfaceTextureHelper: SurfaceTextureHelper): VideoFrame { // Step 1: Video Frame to Bitmap - val inputFrameBitmap = YuvFrame.bitmapFromVideoFrame(frame) ?: return frame + val inputFrameBitmap = yuvFrame.bitmapFromVideoFrame(frame) ?: return frame // Prepare helpers (runs only once or if the dimensions change) initialize( @@ -66,9 +67,6 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid } private fun initialize(width: Int, height: Int, textureHelper: SurfaceTextureHelper) { - // TODO: temporarily disabled due to crash: java.lang.IllegalStateException: release() called on an object with refcount < 1 -// yuvBuffer?.release() - if (this.inputWidth != width || this.inputHeight != height) { Log.d(TAG, "initialize - width: $width height: $height") this.inputWidth = width diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt index 691bd4c89e..28bb464358 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt @@ -9,9 +9,7 @@ import io.github.crow_misia.libyuv.RotateMode import io.github.crow_misia.libyuv.RowStride import org.webrtc.VideoFrame -object YuvFrame { - private const val TAG = "YuvFrame" - +class YuvFrame { private lateinit var webRtcI420Buffer: VideoFrame.I420Buffer private lateinit var libYuvI420Buffer: I420Buffer private var libYuvRotatedI420Buffer: I420Buffer? = null @@ -94,4 +92,8 @@ object YuvFrame { webRtcI420Buffer.release() // Rest of buffers are closed in the methods above } + + companion object { + private const val TAG = "YuvFrame" + } } diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt index bb64a7ab11..85a7362818 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt @@ -44,15 +44,9 @@ class VirtualBackgroundFactory( override fun build(): VideoFrameProcessor { return VideoFrameProcessorWithBitmapFilter { - VirtualBackgroundVideoFilter( - reactContext, backgroundImageUrlString, foregroundThreshold - ) + VirtualBackgroundVideoFilter(reactContext, backgroundImageUrlString, foregroundThreshold) } } - - companion object { - private const val TAG = "VirtualBackgroundFactory" - } } /** @@ -63,10 +57,36 @@ class VirtualBackgroundFactory( */ @Keep private class VirtualBackgroundVideoFilter( - reactContext: ReactApplicationContext, - backgroundImageUrlString: String, + private val reactContext: ReactApplicationContext, + private val backgroundImageUrlString: String, foregroundThreshold: Double = DEFAULT_FOREGROUND_THRESHOLD, ) : BitmapVideoFilter() { + // Loaded asynchronously to avoid blocking the WebRTC capture thread on URL I/O. + // Frames arriving before the load completes fall through unfiltered. + @Volatile + private var virtualBackgroundBitmap: Bitmap? = null + + init { + Thread { loadBackgroundImage() }.start() + } + + private fun loadBackgroundImage() { + virtualBackgroundBitmap = try { + val uri = Uri.parse(backgroundImageUrlString) + if (uri.scheme == null) { // this is a local image + val drawableId = ResourceDrawableIdHelper.getInstance() + .getResourceDrawableId(reactContext, backgroundImageUrlString) + BitmapFactory.decodeResource(reactContext.resources, drawableId) + } else { + val url = URL(backgroundImageUrlString) + BitmapFactory.decodeStream(url.openConnection().getInputStream()) + } + } catch (e: IOException) { + Log.e(TAG, "cant get bitmap for image url: $backgroundImageUrlString", e) + null + } + } + private val options = SelfieSegmenterOptions.Builder().setDetectorMode(SelfieSegmenterOptions.STREAM_MODE) .enableRawSizeMask().build() @@ -93,23 +113,6 @@ private class VirtualBackgroundVideoFilter( } - private val virtualBackgroundBitmap by lazy { - try { - val uri = Uri.parse(backgroundImageUrlString) - if (uri.scheme == null) { // this is a local image - val drawableId = ResourceDrawableIdHelper.getInstance() - .getResourceDrawableId(reactContext, backgroundImageUrlString) - BitmapFactory.decodeResource(reactContext.resources, drawableId) - } else { - val url = URL(backgroundImageUrlString) - BitmapFactory.decodeStream(url.openConnection().getInputStream()) - } - } catch (e: IOException) { - Log.e(TAG, "cant get bitmap for image url: $backgroundImageUrlString", e) - null - } - } - private val foregroundPaint by lazy { // destination - video frame // source - black mask bitmap of person cutout diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift index f838de6da5..be73358c6e 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift @@ -19,10 +19,19 @@ final class BlurBackgroundVideoFrameProcessor: VideoFilter { filter: { input in input.originalImage } ) - self.filter = { input in + self.filter = { [weak self] input in + // `self.filter` is stored on `self`; capture weakly to avoid a retain cycle + // that would otherwise leak the processor (including its CIContext, Vision + // handler, and NotificationCenter observer) for the lifetime of the app. + guard let self = self else { return input.originalImage } + // Blur at half-resolution (4× cheaper) then upscale, mirroring the Android path. // https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/filter/ci/CIGaussianBlur - let backgroundImage = input.originalImage.applyingFilter("CIGaussianBlur", parameters: self.blurParameters) - + let originalExtent = input.originalImage.extent + let halfSize = CGSize(width: originalExtent.width / 2, height: originalExtent.height / 2) + let downscaled = input.originalImage.resize(halfSize) ?? input.originalImage + let blurred = downscaled.applyingFilter("CIGaussianBlur", parameters: self.blurParameters) + let backgroundImage = blurred.resize(originalExtent.size) ?? blurred + return self.backgroundImageFilterProcessor .applyFilter( input.originalPixelBuffer, diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift index eafc5a5057..f50072e448 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift @@ -27,53 +27,71 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { private var cachedValue: CacheValue? private var backgroundImageUrl: String - + private lazy var backgroundImageFilterProcessor = { return BackgroundImageFilterProcessor() }() - - private lazy var backgroundCIImage: CIImage? = { - var bgUIImage: UIImage? - if let url = URL(string: backgroundImageUrl) { - // check if its a local asset - bgUIImage = RCTImageFromLocalAssetURL(url) - if (bgUIImage == nil) { - // if its not a local asset, then try to get it as a remote asset - if let data = try? Data(contentsOf: url) { - bgUIImage = UIImage(data: data) - } else { - NSLog("Failed to convert uri to image: -\(backgroundImageUrl)") - } - } - } - if (bgUIImage != nil) { - return CIImage.init(image: bgUIImage!) - } - return nil - }() - + + // Background-loaded off the capture thread; `Data(contentsOf:)` on a remote URL + // would otherwise block the first frame(s) for the full network fetch duration. + // Guarded by an NSLock because Swift's `lazy var` is not thread-safe for class + // instances and the property is written from a background queue but read from + // the WebRTC capture thread. + private let backgroundImageLock = NSLock() + private var _backgroundCIImage: CIImage? + + private var backgroundCIImage: CIImage? { + backgroundImageLock.lock() + defer { backgroundImageLock.unlock() } + return _backgroundCIImage + } + @available(*, unavailable) override public init( filter: @escaping (Input) -> CIImage ) { fatalError() } - + init(_ backgroundImageUrl: String) { self.backgroundImageUrl = backgroundImageUrl super.init( filter: { input in input.originalImage } ) - - self.filter = { input in - guard let bgImage = self.backgroundCIImage else { return input.originalImage } + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.loadBackgroundImage() + } + + self.filter = { [weak self] input in + // `self.filter` is stored on `self`; capture weakly to avoid a retain cycle + // that would otherwise leak the processor for the lifetime of the app. + guard let self = self, let bgImage = self.backgroundCIImage else { return input.originalImage } let cachedBackgroundImage = self.backgroundImage(image: bgImage, originalImage: input.originalImage, originalImageOrientation: input.originalImageOrientation) - + let outputImage: CIImage = self.backgroundImageFilterProcessor .applyFilter( input.originalPixelBuffer, backgroundImage: cachedBackgroundImage ) ?? input.originalImage - + return outputImage } } + + private func loadBackgroundImage() { + var bgUIImage: UIImage? + if let url = URL(string: backgroundImageUrl) { + bgUIImage = RCTImageFromLocalAssetURL(url) + if bgUIImage == nil { + if let data = try? Data(contentsOf: url) { + bgUIImage = UIImage(data: data) + } else { + NSLog("Failed to convert uri to image: -\(backgroundImageUrl)") + } + } + } + guard let bgUIImage = bgUIImage else { return } + backgroundImageLock.lock() + _backgroundCIImage = CIImage(image: bgUIImage) + backgroundImageLock.unlock() + } /// Returns the cached or processed background image for a given original image (frame image). private func backgroundImage(image: CIImage, originalImage: CIImage, originalImageOrientation: CGImagePropertyOrientation) -> CIImage { diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/VideoFilters.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/VideoFilters.swift index c478ae46aa..b456c6b0a2 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/VideoFilters.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/VideoFilters.swift @@ -79,7 +79,11 @@ open class VideoFilter: NSObject, VideoFrameProcessorDelegate { ) updateRotation() } - + + deinit { + NotificationCenter.default.removeObserver(self) + } + @objc private func updateRotation() { DispatchQueue.main.async { self.sceneOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .unknown From c7f82a363b35dbb0a4477c99f5323040f0bf8fdd Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Thu, 23 Apr 2026 16:28:25 +0200 Subject: [PATCH 02/15] code-rabbit fixes --- .../src/contexts/BackgroundFilters.tsx | 41 ++++++++++++------- .../factories/VirtualBackgroundFactory.kt | 14 +++++-- .../ImageBackgroundVideoFrameProcessor.swift | 4 +- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index 86ac0333b5..c532013785 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -80,6 +80,10 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { // Holds the exact native filter name so we can reapply on track replacement // (camera flip, enable-after-disable). State alone can't distinguish // `BackgroundBlur*` from `Blur*` — both use `{ blur: intensity }`. + // + // Also doubles as an invalidation signal for in-flight async apply calls: we + // set it before any await and check it still matches after, so a later + // apply/disable that mutates the ref causes the stale call to bail out. const lastAppliedFilterNameRef = useRef(null); const [currentBackgroundFilter, setCurrentBackgroundFilter] = @@ -90,18 +94,21 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { if (!isSupported) { return; } - if (!isBackgroundBlurRegisteredRef.current) { - await videoFiltersModule?.registerBackgroundBlurVideoFilters(); - isBackgroundBlurRegisteredRef.current = true; - } let filterName = 'BackgroundBlurMedium'; if (blurIntensity === 'heavy') { filterName = 'BackgroundBlurHeavy'; } else if (blurIntensity === 'light') { filterName = 'BackgroundBlurLight'; } - call?.tracer.trace('backgroundFilters.apply', filterName); + // Set intent before awaiting so a concurrent apply/disable that mutates + // the ref supersedes this call; we bail after the await if stale. lastAppliedFilterNameRef.current = filterName; + if (!isBackgroundBlurRegisteredRef.current) { + await videoFiltersModule?.registerBackgroundBlurVideoFilters(); + if (lastAppliedFilterNameRef.current !== filterName) return; + isBackgroundBlurRegisteredRef.current = true; + } + call?.tracer.trace('backgroundFilters.apply', filterName); (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -117,18 +124,19 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { if (!isSupported) { return; } - if (!isVideoBlurRegisteredRef.current) { - await videoFiltersModule?.registerBlurVideoFilters(); - isVideoBlurRegisteredRef.current = true; - } let filterName = 'BlurMedium'; if (blurIntensity === 'heavy') { filterName = 'BlurHeavy'; } else if (blurIntensity === 'light') { filterName = 'BlurLight'; } - call?.tracer.trace('videoFilters.apply', filterName); lastAppliedFilterNameRef.current = filterName; + if (!isVideoBlurRegisteredRef.current) { + await videoFiltersModule?.registerBlurVideoFilters(); + if (lastAppliedFilterNameRef.current !== filterName) return; + isVideoBlurRegisteredRef.current = true; + } + call?.tracer.trace('videoFilters.apply', filterName); (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -146,14 +154,15 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { } const source = Image.resolveAssetSource(imageSource); const imageUri = source.uri; + const filterName = `VirtualBackground-${imageUri}`; + lastAppliedFilterNameRef.current = filterName; const registeredImageFiltersSet = registeredImageFiltersSetRef.current; if (!registeredImageFiltersSet.has(imageUri)) { await videoFiltersModule?.registerVirtualBackgroundFilter(imageSource); + if (lastAppliedFilterNameRef.current !== filterName) return; registeredImageFiltersSetRef.current.add(imageUri); } - const filterName = `VirtualBackground-${imageUri}`; call?.tracer.trace('backgroundFilters.apply', filterName); - lastAppliedFilterNameRef.current = filterName; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() .forEach((track) => { @@ -169,6 +178,9 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { return; } call?.tracer.trace('backgroundFilters.disableAll', null); + // Clearing the ref also invalidates any in-flight apply call: its + // post-await stale check (`lastAppliedFilterNameRef.current !== filterName`) + // will then bail before re-applying a filter the user just turned off. lastAppliedFilterNameRef.current = null; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() @@ -178,8 +190,9 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { setCurrentBackgroundFilter(undefined); }, [call]); - // Reapply the active filter on track replacement (camera flip, enable-after-disable) - // and release native filter state when the provider unmounts or the call changes. + // Reapply the active filter on track replacement (camera flip, enable-after- + // disable) and release native filter state when the provider unmounts or the + // call changes. useEffect(() => { if (!call || !isSupported) return; const registeredImageFiltersSet = registeredImageFiltersSetRef.current; diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt index 85a7362818..5ee7d1a5b1 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt @@ -78,11 +78,18 @@ private class VirtualBackgroundVideoFilter( .getResourceDrawableId(reactContext, backgroundImageUrlString) BitmapFactory.decodeResource(reactContext.resources, drawableId) } else { - val url = URL(backgroundImageUrlString) - BitmapFactory.decodeStream(url.openConnection().getInputStream()) + val connection = URL(backgroundImageUrlString).openConnection().apply { + connectTimeout = REMOTE_IMAGE_TIMEOUT_MS + readTimeout = REMOTE_IMAGE_TIMEOUT_MS + } + connection.getInputStream().use { stream -> + BitmapFactory.decodeStream(stream) + } } } catch (e: IOException) { - Log.e(TAG, "cant get bitmap for image url: $backgroundImageUrlString", e) + // URLs may carry signed-access query tokens; log only the host. + val host = Uri.parse(backgroundImageUrlString).host ?: "local" + Log.e(TAG, "cant get bitmap for image (host=$host)", e) null } } @@ -263,6 +270,7 @@ private class VirtualBackgroundVideoFilter( companion object { private const val TAG = "VirtualBackgroundVideoFilter" + private const val REMOTE_IMAGE_TIMEOUT_MS = 10_000 } } diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift index f50072e448..f417ae6ecd 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift @@ -83,7 +83,9 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { if let data = try? Data(contentsOf: url) { bgUIImage = UIImage(data: data) } else { - NSLog("Failed to convert uri to image: -\(backgroundImageUrl)") + // URLs may carry signed-access query tokens; log only the host. + let host = url.host ?? "local" + NSLog("Failed to load virtual-background image (host=\(host))") } } } From 03ebb8f25aa4bc3323b62bb2b459809daf62059e Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 24 Apr 2026 13:47:30 +0200 Subject: [PATCH 03/15] android filters dispose --- .../common/VideoFrameWithBitmapFilter.kt | 20 +++++++++++++++++++ .../common/YuvFrame.kt | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt index fce062c265..82b53da806 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt @@ -90,6 +90,26 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid } } + // Upstream `VideoEffectProcessor.dispose()` posts to the capturer's handler, + // so this runs on the GL thread serialized with `onFrameCaptured` → + // `process()`. All cleanup can be inline: no in-flight `process()` can race, + // and GL ops have the correct context / thread. The texture is deleted + // regardless of whether `initialize()` ever ran (enable-then-disable before + // first frame would otherwise leak it on drivers where the no-context + // `glGenTextures` in `init` still produced a valid id). + override fun dispose() { + yuvFrame.close() + inputFrameBitmap?.recycle() + inputFrameBitmap = null + yuvConverter.release() + inputBuffer?.release() + inputBuffer = null + if (textures[0] != 0) { + GLES20.glDeleteTextures(1, intArrayOf(textures[0]), 0) + textures[0] = 0 + } + } + companion object { private const val TAG = "VideoFrameProcessorWithBitmapFilter" } diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt index 28bb464358..c70f581885 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt @@ -93,6 +93,18 @@ class YuvFrame { // Rest of buffers are closed in the methods above } + /** + * Releases the size-dependent native buffers held across frames. libyuv-android + * buffers are backed by direct ByteBuffers whose off-heap memory is only freed + * when `close()` is called — GC alone will not reclaim it. Idempotent. + */ + fun close() { + libYuvRotatedI420Buffer?.close() + libYuvRotatedI420Buffer = null + libYuvAbgrBuffer?.close() + libYuvAbgrBuffer = null + } + companion object { private const val TAG = "YuvFrame" } From 2bd4ccd97b30ba3752fe27ef8f2ac59d452d0a09 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 24 Apr 2026 14:15:29 +0200 Subject: [PATCH 04/15] fix: mismatched bg and fg on iOS filter --- .../BlurBackgroundVideoFrameProcessor.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift index be73358c6e..089aa35ebe 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift @@ -25,11 +25,18 @@ final class BlurBackgroundVideoFrameProcessor: VideoFilter { // handler, and NotificationCenter observer) for the lifetime of the app. guard let self = self else { return input.originalImage } // Blur at half-resolution (4× cheaper) then upscale, mirroring the Android path. - // https://developer.apple.com/library/archive/documentation/GraphicsImaging/Reference/CoreImageFilterReference/index.html#//apple_ref/doc/filter/ci/CIGaussianBlur + // `clampedToExtent` avoids transparent edges from the blur kernel sampling + // past the image border; `cropped(to:)` undoes the extent expansion that + // CIGaussianBlur applies, so the upscale maps the background 1:1 back onto + // the original frame — without it the background slides relative to the + // foreground mask. let originalExtent = input.originalImage.extent let halfSize = CGSize(width: originalExtent.width / 2, height: originalExtent.height / 2) let downscaled = input.originalImage.resize(halfSize) ?? input.originalImage - let blurred = downscaled.applyingFilter("CIGaussianBlur", parameters: self.blurParameters) + let blurred = downscaled + .clampedToExtent() + .applyingFilter("CIGaussianBlur", parameters: self.blurParameters) + .cropped(to: downscaled.extent) let backgroundImage = blurred.resize(originalExtent.size) ?? blurred return self.backgroundImageFilterProcessor From 707a0187bd657c00e0609cd95b98bd8dfd89a6ca Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 24 Apr 2026 14:27:36 +0200 Subject: [PATCH 05/15] reduce segmentation cost for iOS filters --- .../BackgroundImageFilterProcessor.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift index d74c88518f..a5848f967c 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift @@ -15,6 +15,8 @@ import Vision /// replacement or blurring. @available(iOS 15.0, *) final class BackgroundImageFilterProcessor { + private static let segmentationTargetHeight: CGFloat = 540 + private let requestHandler = VNSequenceRequestHandler() private let request: VNGeneratePersonSegmentationRequest @@ -45,10 +47,28 @@ final class BackgroundImageFilterProcessor { backgroundImage: CIImage ) -> CIImage? { do { - try requestHandler.perform([request], on: buffer) + let originalImage = CIImage(cvPixelBuffer: buffer) + + // Vision's segmentation cost scales with input resolution. Running at + // ~540p (vs. 1080p) is 2–4× faster end-to-end, and the existing + // mask-upscale step below rescales whatever mask size Vision returns + // back to the frame — so no other code has to change. Edge softness + // is hidden by the blend-with-mask step that follows. + let segInput: CIImage + if originalImage.extent.height > Self.segmentationTargetHeight { + let scale = Self.segmentationTargetHeight / originalImage.extent.height + let targetSize = CGSize( + width: originalImage.extent.width * scale, + height: Self.segmentationTargetHeight + ) + segInput = originalImage.resize(targetSize) ?? originalImage + } else { + segInput = originalImage + } + + try requestHandler.perform([request], on: segInput, orientation: .up) if let maskPixelBuffer = request.results?.first?.pixelBuffer { - let originalImage = CIImage(cvPixelBuffer: buffer) var maskImage = CIImage(cvPixelBuffer: maskPixelBuffer) // Scale the mask image to fit the bounds of the video frame. From c8da99464c75a0cd74dfc1213cc6cd9bdaee21a2 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Fri, 24 Apr 2026 14:50:44 +0200 Subject: [PATCH 06/15] minimal documentation --- .../src/contexts/BackgroundFilters.tsx | 23 ++++++++----------- .../common/VideoFrameWithBitmapFilter.kt | 10 +++----- .../common/YuvFrame.kt | 6 +---- .../factories/VirtualBackgroundFactory.kt | 4 ++-- .../BlurBackgroundVideoFrameProcessor.swift | 13 ++++------- .../ImageBackgroundVideoFrameProcessor.swift | 10 +++----- .../BackgroundImageFilterProcessor.swift | 7 ++---- 7 files changed, 24 insertions(+), 49 deletions(-) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index c532013785..c5b7635119 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -77,13 +77,12 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { const isBackgroundBlurRegisteredRef = useRef(false); const isVideoBlurRegisteredRef = useRef(false); const registeredImageFiltersSetRef = useRef(new Set()); - // Holds the exact native filter name so we can reapply on track replacement - // (camera flip, enable-after-disable). State alone can't distinguish - // `BackgroundBlur*` from `Blur*` — both use `{ blur: intensity }`. + // Native filter name for reapply on track replacement (camera flip, + // enable-after-disable). State alone can't distinguish `BackgroundBlur*` + // from `Blur*` — both hold `{ blur: intensity }`. // - // Also doubles as an invalidation signal for in-flight async apply calls: we - // set it before any await and check it still matches after, so a later - // apply/disable that mutates the ref causes the stale call to bail out. + // Also used as a staleness signal: apply sets it before awaiting and bails + // if a later apply/disable changed it. const lastAppliedFilterNameRef = useRef(null); const [currentBackgroundFilter, setCurrentBackgroundFilter] = @@ -100,8 +99,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { } else if (blurIntensity === 'light') { filterName = 'BackgroundBlurLight'; } - // Set intent before awaiting so a concurrent apply/disable that mutates - // the ref supersedes this call; we bail after the await if stale. + // Mark intent before awaiting so a later apply/disable can invalidate us. lastAppliedFilterNameRef.current = filterName; if (!isBackgroundBlurRegisteredRef.current) { await videoFiltersModule?.registerBackgroundBlurVideoFilters(); @@ -178,9 +176,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { return; } call?.tracer.trace('backgroundFilters.disableAll', null); - // Clearing the ref also invalidates any in-flight apply call: its - // post-await stale check (`lastAppliedFilterNameRef.current !== filterName`) - // will then bail before re-applying a filter the user just turned off. + // Clearing the ref invalidates any in-flight apply — its stale check will bail. lastAppliedFilterNameRef.current = null; (call?.camera.state.mediaStream as MediaStream | undefined) ?.getVideoTracks() @@ -190,9 +186,8 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { setCurrentBackgroundFilter(undefined); }, [call]); - // Reapply the active filter on track replacement (camera flip, enable-after- - // disable) and release native filter state when the provider unmounts or the - // call changes. + // Reapplies the filter on track replacement (flip, enable-after-disable). + // Releases native filter state on unmount / call change. useEffect(() => { if (!call || !isSupported) return; const registeredImageFiltersSet = registeredImageFiltersSetRef.current; diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt index 82b53da806..d62f23a3fc 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt @@ -90,13 +90,9 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid } } - // Upstream `VideoEffectProcessor.dispose()` posts to the capturer's handler, - // so this runs on the GL thread serialized with `onFrameCaptured` → - // `process()`. All cleanup can be inline: no in-flight `process()` can race, - // and GL ops have the correct context / thread. The texture is deleted - // regardless of whether `initialize()` ever ran (enable-then-disable before - // first frame would otherwise leak it on drivers where the no-context - // `glGenTextures` in `init` still produced a valid id). + // Runs on the GL thread, serialized with `process()` — inline cleanup is safe. + // The texture is always deleted: `glGenTextures` in `init` can return a valid id + // even if `initialize()` never ran, so enable-then-disable would otherwise leak it. override fun dispose() { yuvFrame.close() inputFrameBitmap?.recycle() diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt index c70f581885..ddd5c81abe 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/YuvFrame.kt @@ -93,11 +93,7 @@ class YuvFrame { // Rest of buffers are closed in the methods above } - /** - * Releases the size-dependent native buffers held across frames. libyuv-android - * buffers are backed by direct ByteBuffers whose off-heap memory is only freed - * when `close()` is called — GC alone will not reclaim it. Idempotent. - */ + /** Frees the libyuv buffers. GC alone won't reclaim their off-heap memory. Idempotent. */ fun close() { libYuvRotatedI420Buffer?.close() libYuvRotatedI420Buffer = null diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt index 5ee7d1a5b1..557982a200 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt @@ -61,8 +61,8 @@ private class VirtualBackgroundVideoFilter( private val backgroundImageUrlString: String, foregroundThreshold: Double = DEFAULT_FOREGROUND_THRESHOLD, ) : BitmapVideoFilter() { - // Loaded asynchronously to avoid blocking the WebRTC capture thread on URL I/O. - // Frames arriving before the load completes fall through unfiltered. + // Loaded off-thread so a slow URL doesn't block the capture thread. + // Frames arriving before the load finishes fall through unfiltered. @Volatile private var virtualBackgroundBitmap: Bitmap? = null diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift index 089aa35ebe..f77fcf8518 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift @@ -20,16 +20,11 @@ final class BlurBackgroundVideoFrameProcessor: VideoFilter { ) self.filter = { [weak self] input in - // `self.filter` is stored on `self`; capture weakly to avoid a retain cycle - // that would otherwise leak the processor (including its CIContext, Vision - // handler, and NotificationCenter observer) for the lifetime of the app. + // `[weak self]`: the closure is stored on `self` — a strong capture would leak the processor. guard let self = self else { return input.originalImage } - // Blur at half-resolution (4× cheaper) then upscale, mirroring the Android path. - // `clampedToExtent` avoids transparent edges from the blur kernel sampling - // past the image border; `cropped(to:)` undoes the extent expansion that - // CIGaussianBlur applies, so the upscale maps the background 1:1 back onto - // the original frame — without it the background slides relative to the - // foreground mask. + // Blur at half resolution for speed. + // `clampedToExtent` + `cropped(to:)` keep the blurred image at the input's + // extent; without it the upscaled background drifts relative to the foreground. let originalExtent = input.originalImage.extent let halfSize = CGSize(width: originalExtent.width / 2, height: originalExtent.height / 2) let downscaled = input.originalImage.resize(halfSize) ?? input.originalImage diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift index f417ae6ecd..0a9b56345e 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift @@ -30,11 +30,8 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { private lazy var backgroundImageFilterProcessor = { return BackgroundImageFilterProcessor() }() - // Background-loaded off the capture thread; `Data(contentsOf:)` on a remote URL - // would otherwise block the first frame(s) for the full network fetch duration. - // Guarded by an NSLock because Swift's `lazy var` is not thread-safe for class - // instances and the property is written from a background queue but read from - // the WebRTC capture thread. + // Loaded on a background queue so a slow URL doesn't block the capture thread. + // NSLock because the load thread writes it and the capture thread reads it. private let backgroundImageLock = NSLock() private var _backgroundCIImage: CIImage? @@ -60,8 +57,7 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { } self.filter = { [weak self] input in - // `self.filter` is stored on `self`; capture weakly to avoid a retain cycle - // that would otherwise leak the processor for the lifetime of the app. + // `[weak self]`: the closure is stored on `self` — a strong capture would leak the processor. guard let self = self, let bgImage = self.backgroundCIImage else { return input.originalImage } let cachedBackgroundImage = self.backgroundImage(image: bgImage, originalImage: input.originalImage, originalImageOrientation: input.originalImageOrientation) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift index a5848f967c..fd60d9610a 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift @@ -49,11 +49,8 @@ final class BackgroundImageFilterProcessor { do { let originalImage = CIImage(cvPixelBuffer: buffer) - // Vision's segmentation cost scales with input resolution. Running at - // ~540p (vs. 1080p) is 2–4× faster end-to-end, and the existing - // mask-upscale step below rescales whatever mask size Vision returns - // back to the frame — so no other code has to change. Edge softness - // is hidden by the blend-with-mask step that follows. + // Run segmentation at ~540p — Vision's cost scales with input size. + // The mask-upscale step below already handles whatever size Vision returns. let segInput: CIImage if originalImage.extent.height > Self.segmentationTargetHeight { let scale = Self.segmentationTargetHeight / originalImage.extent.height From 4cf1e17b6aa4c3a898f725ce1d1f2ee5db5e34e2 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 10:44:27 +0200 Subject: [PATCH 07/15] filter uregister support --- .../src/contexts/BackgroundFilters.tsx | 3 +++ .../VideoFiltersReactNativeModule.kt | 22 ++++++++++++++-- .../ios/VideoFiltersReactNative.mm | 3 +++ .../ios/VideoFiltersReactNative.swift | 26 ++++++++++++++++--- .../video-filters-react-native/src/index.ts | 9 +++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index c5b7635119..148e923cf5 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -207,6 +207,9 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { .forEach((track) => { track._setVideoEffect(null); }); + // Drop native processor refs so they can be deallocated — without this, + // the ProcessorProvider registry pins them for the app's lifetime. + videoFiltersModule?.unregisterAllFilters?.().catch(() => {}); lastAppliedFilterNameRef.current = null; isBackgroundBlurRegisteredRef.current = false; isVideoBlurRegisteredRef.current = false; diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt index ec2dc082b9..aba3490c7e 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt @@ -10,6 +10,11 @@ import com.streamio.videofiltersreactnative.factories.* class VideoFiltersReactNativeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + // Tracks names registered with the global `ProcessorProvider` so we can drop + // them on `unregisterAllFilters`. Without this, factories accumulate for the + // life of the app. + private val registeredNames = mutableSetOf() + override fun getName(): String { return NAME } @@ -36,15 +41,18 @@ class VideoFiltersReactNativeModule(reactContext: ReactApplicationContext) : "BackgroundBlurHeavy", BackgroundBlurFactory(BlurIntensity.HEAVY) ) + registeredNames.addAll(listOf("BackgroundBlurLight", "BackgroundBlurMedium", "BackgroundBlurHeavy")) promise.resolve(true) } @ReactMethod fun registerVirtualBackgroundFilter(backgroundImageUrlString: String, promise: Promise) { + val name = "VirtualBackground-$backgroundImageUrlString" ProcessorProvider.addProcessor( - "VirtualBackground-$backgroundImageUrlString", + name, VirtualBackgroundFactory(reactApplicationContext, backgroundImageUrlString) ) + registeredNames.add(name) promise.resolve(true) } @@ -53,9 +61,19 @@ class VideoFiltersReactNativeModule(reactContext: ReactApplicationContext) : ProcessorProvider.addProcessor("BlurLight", VideoBlurFactory(VideoBlurIntensity.LIGHT)) ProcessorProvider.addProcessor("BlurMedium", VideoBlurFactory(VideoBlurIntensity.MEDIUM)) ProcessorProvider.addProcessor("BlurHeavy", VideoBlurFactory(VideoBlurIntensity.HEAVY)) + registeredNames.addAll(listOf("BlurLight", "BlurMedium", "BlurHeavy")) + promise.resolve(true) + } + + @ReactMethod + fun unregisterAllFilters(promise: Promise) { + for (name in registeredNames) { + ProcessorProvider.removeProcessor(name) + } + registeredNames.clear() promise.resolve(true) } - + companion object { const val NAME = "VideoFiltersReactNative" } diff --git a/packages/video-filters-react-native/ios/VideoFiltersReactNative.mm b/packages/video-filters-react-native/ios/VideoFiltersReactNative.mm index 04957fc044..e11598ab3c 100644 --- a/packages/video-filters-react-native/ios/VideoFiltersReactNative.mm +++ b/packages/video-filters-react-native/ios/VideoFiltersReactNative.mm @@ -12,6 +12,9 @@ @interface RCT_EXTERN_MODULE(VideoFiltersReactNative, NSObject) RCT_EXTERN_METHOD(registerBlurVideoFilters:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(unregisterAllFilters:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + + (BOOL)requiresMainQueueSetup { return NO; diff --git a/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift b/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift index dbc329411c..a7df570d89 100644 --- a/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift +++ b/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift @@ -1,19 +1,27 @@ @objc(VideoFiltersReactNative) class VideoFiltersReactNative: NSObject { - + + // Names added to the global `ProcessorProvider` registry, so we can drop them + // on `unregisterAllFilters` — otherwise the processors (with their CIContext, + // Vision handler, and NotificationCenter observer) live for the app's lifetime. + private static var registeredNames = Set() + @available(iOS 15.0, *) @objc(registerBackgroundBlurVideoFilters:withRejecter:) func registerBackgroundBlurVideoFilters(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { ProcessorProvider.addProcessor(BlurBackgroundVideoFrameProcessor(blurIntensity: BlurIntensity.light), forName: "BackgroundBlurLight") ProcessorProvider.addProcessor(BlurBackgroundVideoFrameProcessor(blurIntensity: BlurIntensity.medium), forName: "BackgroundBlurMedium") ProcessorProvider.addProcessor(BlurBackgroundVideoFrameProcessor(blurIntensity: BlurIntensity.heavy), forName: "BackgroundBlurHeavy") + Self.registeredNames.formUnion(["BackgroundBlurLight", "BackgroundBlurMedium", "BackgroundBlurHeavy"]) resolve(true) } - + @available(iOS 15.0, *) @objc(registerVirtualBackgroundFilter:withResolver:withRejecter:) func registerVirtualBackgroundFilter(backgroundImageUrlString: String, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - ProcessorProvider.addProcessor(ImageBackgroundVideoFrameProcessor(backgroundImageUrlString), forName: "VirtualBackground-\(backgroundImageUrlString)") + let name = "VirtualBackground-\(backgroundImageUrlString)" + ProcessorProvider.addProcessor(ImageBackgroundVideoFrameProcessor(backgroundImageUrlString), forName: name) + Self.registeredNames.insert(name) resolve(true) } @@ -23,6 +31,16 @@ class VideoFiltersReactNative: NSObject { ProcessorProvider.addProcessor(BlurVideoFrameProcessor(blurIntensity: VideoBlurIntensity.light), forName: "BlurLight") ProcessorProvider.addProcessor(BlurVideoFrameProcessor(blurIntensity: VideoBlurIntensity.medium), forName: "BlurMedium") ProcessorProvider.addProcessor(BlurVideoFrameProcessor(blurIntensity: VideoBlurIntensity.heavy), forName: "BlurHeavy") + Self.registeredNames.formUnion(["BlurLight", "BlurMedium", "BlurHeavy"]) resolve(true) - } + } + + @objc(unregisterAllFilters:withRejecter:) + func unregisterAllFilters(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + for name in Self.registeredNames { + ProcessorProvider.removeProcessor(name) + } + Self.registeredNames.removeAll() + resolve(true) + } } diff --git a/packages/video-filters-react-native/src/index.ts b/packages/video-filters-react-native/src/index.ts index daa5d33079..dfb9a1d3d4 100644 --- a/packages/video-filters-react-native/src/index.ts +++ b/packages/video-filters-react-native/src/index.ts @@ -56,3 +56,12 @@ export async function registerVirtualBackgroundFilter( export async function registerBlurVideoFilters(): Promise { return await VideoFiltersReactNative.registerBlurVideoFilters(); } + +/** + * Unregisters all filters that were previously registered via this module, + * allowing the native processor instances to be released. Safe to call even + * if no filters were registered. + */ +export async function unregisterAllFilters(): Promise { + return await VideoFiltersReactNative.unregisterAllFilters(); +} From 17d2b67fe62581986ea7efe8c99ef7c57bef6979 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 10:52:07 +0200 Subject: [PATCH 08/15] minimise doc string --- .../src/contexts/BackgroundFilters.tsx | 15 ++++++--------- .../VideoFiltersReactNativeModule.kt | 5 ++--- .../common/VideoFrameWithBitmapFilter.kt | 6 +++--- .../ios/VideoFiltersReactNative.swift | 5 ++--- .../BlurBackgroundVideoFrameProcessor.swift | 6 +++--- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index 148e923cf5..67c2130edf 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -77,12 +77,9 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { const isBackgroundBlurRegisteredRef = useRef(false); const isVideoBlurRegisteredRef = useRef(false); const registeredImageFiltersSetRef = useRef(new Set()); - // Native filter name for reapply on track replacement (camera flip, - // enable-after-disable). State alone can't distinguish `BackgroundBlur*` - // from `Blur*` — both hold `{ blur: intensity }`. - // - // Also used as a staleness signal: apply sets it before awaiting and bails - // if a later apply/disable changed it. + // The currently applied native filter name. Used to reapply on track + // replacement, and as a staleness signal so a later apply/disable can + // invalidate an in-flight apply() call. const lastAppliedFilterNameRef = useRef(null); const [currentBackgroundFilter, setCurrentBackgroundFilter] = @@ -99,7 +96,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { } else if (blurIntensity === 'light') { filterName = 'BackgroundBlurLight'; } - // Mark intent before awaiting so a later apply/disable can invalidate us. + // Set before awaiting so a later apply/disable can mark this call stale. lastAppliedFilterNameRef.current = filterName; if (!isBackgroundBlurRegisteredRef.current) { await videoFiltersModule?.registerBackgroundBlurVideoFilters(); @@ -207,8 +204,8 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { .forEach((track) => { track._setVideoEffect(null); }); - // Drop native processor refs so they can be deallocated — without this, - // the ProcessorProvider registry pins them for the app's lifetime. + // Drop native processor refs so they can be deallocated. Otherwise the + // ProcessorProvider registry holds them for the app's lifetime. videoFiltersModule?.unregisterAllFilters?.().catch(() => {}); lastAppliedFilterNameRef.current = null; isBackgroundBlurRegisteredRef.current = false; diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt index aba3490c7e..b1efdf4be4 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/VideoFiltersReactNativeModule.kt @@ -10,9 +10,8 @@ import com.streamio.videofiltersreactnative.factories.* class VideoFiltersReactNativeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { - // Tracks names registered with the global `ProcessorProvider` so we can drop - // them on `unregisterAllFilters`. Without this, factories accumulate for the - // life of the app. + // Names we add to the global ProcessorProvider, so unregisterAllFilters can + // release them. Otherwise factories accumulate for the app's lifetime. private val registeredNames = mutableSetOf() override fun getName(): String { diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt index d62f23a3fc..3b588a3818 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt @@ -90,9 +90,9 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid } } - // Runs on the GL thread, serialized with `process()` — inline cleanup is safe. - // The texture is always deleted: `glGenTextures` in `init` can return a valid id - // even if `initialize()` never ran, so enable-then-disable would otherwise leak it. + // Runs on the GL thread serialized with process() — inline cleanup is safe. + // Always delete the texture: glGenTextures in init can return a valid id even + // before initialize() runs, so enable-then-disable would leak it otherwise. override fun dispose() { yuvFrame.close() inputFrameBitmap?.recycle() diff --git a/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift b/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift index a7df570d89..725fdd0e27 100644 --- a/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift +++ b/packages/video-filters-react-native/ios/VideoFiltersReactNative.swift @@ -1,9 +1,8 @@ @objc(VideoFiltersReactNative) class VideoFiltersReactNative: NSObject { - // Names added to the global `ProcessorProvider` registry, so we can drop them - // on `unregisterAllFilters` — otherwise the processors (with their CIContext, - // Vision handler, and NotificationCenter observer) live for the app's lifetime. + // Names we add to the global ProcessorProvider, so unregisterAllFilters can + // release them. Otherwise the processors live for the app's lifetime. private static var registeredNames = Set() @available(iOS 15.0, *) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift index f77fcf8518..48ecaae463 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/BlurBackgroundVideoFrameProcessor.swift @@ -22,9 +22,9 @@ final class BlurBackgroundVideoFrameProcessor: VideoFilter { self.filter = { [weak self] input in // `[weak self]`: the closure is stored on `self` — a strong capture would leak the processor. guard let self = self else { return input.originalImage } - // Blur at half resolution for speed. - // `clampedToExtent` + `cropped(to:)` keep the blurred image at the input's - // extent; without it the upscaled background drifts relative to the foreground. + // Blur at half resolution for speed. clampedToExtent + cropped(to:) + // keep the result at the original extent; without them the background + // drifts relative to the foreground. let originalExtent = input.originalImage.extent let halfSize = CGSize(width: originalExtent.width / 2, height: originalExtent.height / 2) let downscaled = input.originalImage.resize(halfSize) ?? input.originalImage From 0c79f8f95e2d93fe1b0fe76014eb74eaa8825b05 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 11:29:59 +0200 Subject: [PATCH 09/15] add docs --- packages/react-native-sdk/package.json | 2 +- .../BackgroundImageFilterProcessor.swift | 126 +++++++++++++----- .../react-native/dogfood/ios/Podfile.lock | 8 +- sample-apps/react-native/dogfood/package.json | 2 +- yarn.lock | 16 ++- 5 files changed, 110 insertions(+), 44 deletions(-) diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index 6caad7742f..f61e89c7de 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -127,7 +127,7 @@ "@react-native/babel-preset": "^0.81.5", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.1.3", + "@stream-io/react-native-webrtc": "137.2.0-alpha.5", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "13.3.3", diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift index fd60d9610a..ce47c91696 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/Utils/BackgroundImageFilterProcessor.swift @@ -7,12 +7,12 @@ import CoreImage.CIFilterBuiltins import Foundation import Vision -/// Processes a video frame to create a new image with a custom background. +/// Blends a video frame with a custom background using a Vision-generated mask. /// -/// This class generates a person segmentation mask using Vision, scales the mask -/// to match the video frame size, and blends the original image with a provided -/// background image using the mask. This allows for effects like background -/// replacement or blurring. +/// Segmentation runs asynchronously: each `applyFilter` call composites with the last +/// completed mask and only kicks a new Vision request if one isn't already in flight. +/// This keeps the capture thread unblocked at the cost of ≤1–2 frames of mask staleness, +/// which is imperceptible in practice (Android uses the same pattern with ML Kit). @available(iOS 15.0, *) final class BackgroundImageFilterProcessor { private static let segmentationTargetHeight: CGFloat = 540 @@ -20,6 +20,17 @@ final class BackgroundImageFilterProcessor { private let requestHandler = VNSequenceRequestHandler() private let request: VNGeneratePersonSegmentationRequest + // Async segmentation pipeline. `ciContext` snapshots `CIImage`s to `CGImage`s so + // Vision doesn't share storage with the camera buffer pool or its own pooled result + // buffers. `segQueue` serialises Vision calls — `VNSequenceRequestHandler` isn't + // thread-safe under concurrent use. `segLock` guards `lastMask` and `inFlight`, + // both shared between the capture thread and `segQueue`. + private let ciContext = CIContext(options: [.useSoftwareRenderer: false]) + private let segQueue = DispatchQueue(label: "io.getstream.video.segmentation", qos: .userInitiated) + private let segLock = NSLock() + private var lastMask: CIImage? + private var inFlight = false + /// Initializes a new `BackgroundImageFilterProcessor` instance. /// @@ -41,51 +52,94 @@ final class BackgroundImageFilterProcessor { /// - Parameters: /// - buffer: The video frame to process as a `CVPixelBuffer`. /// - backgroundImage: The background image to blend with the foreground. - /// - Returns: A new `CIImage` with the processed frame, or `nil` if an error occurs. + /// - Returns: The blended `CIImage`. If no mask is ready yet (typical for the first + /// 1–2 frames of a session), returns `originalImage` as a pass-through. Returns + /// `nil` if the blend filter itself fails. func applyFilter( _ buffer: CVPixelBuffer, backgroundImage: CIImage ) -> CIImage? { - do { - let originalImage = CIImage(cvPixelBuffer: buffer) + let originalImage = CIImage(cvPixelBuffer: buffer) + + segLock.lock() + let mask = lastMask + let shouldDispatch = !inFlight + if shouldDispatch { + inFlight = true + } + segLock.unlock() - // Run segmentation at ~540p — Vision's cost scales with input size. - // The mask-upscale step below already handles whatever size Vision returns. - let segInput: CIImage - if originalImage.extent.height > Self.segmentationTargetHeight { - let scale = Self.segmentationTargetHeight / originalImage.extent.height - let targetSize = CGSize( - width: originalImage.extent.width * scale, - height: Self.segmentationTargetHeight - ) - segInput = originalImage.resize(targetSize) ?? originalImage + if shouldDispatch { + let segInput = downscaleForSeg(originalImage) + // detach from the camera pool — `VideoFilter` will write the composite back into `buffer` + if let segCG = ciContext.createCGImage(segInput, from: segInput.extent) { + segQueue.async { [weak self] in + self?.runSegmentation(on: segCG) + } } else { - segInput = originalImage + segLock.lock() + inFlight = false + segLock.unlock() } + } + + guard var maskImage = mask else { + return originalImage + } - try requestHandler.perform([request], on: segInput, orientation: .up) + // Scale the mask image to fit the bounds of the video frame. + let scaleX = originalImage.extent.width / maskImage.extent.width + let scaleY = originalImage.extent.height / maskImage.extent.height + maskImage = maskImage.transformed(by: .init(scaleX: scaleX, y: scaleY)) - if let maskPixelBuffer = request.results?.first?.pixelBuffer { - var maskImage = CIImage(cvPixelBuffer: maskPixelBuffer) + // Blend the original, background, and mask images. + let blendFilter = CIFilter.blendWithMask() + blendFilter.inputImage = originalImage + blendFilter.backgroundImage = backgroundImage + blendFilter.maskImage = maskImage - // Scale the mask image to fit the bounds of the video frame. - let scaleX = originalImage.extent.width / maskImage.extent.width - let scaleY = originalImage.extent.height / maskImage.extent.height - maskImage = maskImage.transformed(by: .init(scaleX: scaleX, y: scaleY)) + return blendFilter.outputImage + } - // Blend the original, background, and mask images. - let blendFilter = CIFilter.blendWithMask() - blendFilter.inputImage = originalImage - blendFilter.backgroundImage = backgroundImage - blendFilter.maskImage = maskImage + private func downscaleForSeg(_ image: CIImage) -> CIImage { + // Run segmentation at ~540p — Vision's cost scales with input size. + // The mask-upscale step below already handles whatever size Vision returns. + if image.extent.height > Self.segmentationTargetHeight { + let scale = Self.segmentationTargetHeight / image.extent.height + let targetSize = CGSize( + width: image.extent.width * scale, + height: Self.segmentationTargetHeight + ) + return image.resize(targetSize) ?? image + } + return image + } - let result = blendFilter.outputImage - return result - } else { - return nil + /// Runs on `segQueue`. Performs Vision on the snapshotted `CGImage` and stores the + /// result mask under `segLock`. `inFlight` is always cleared via `defer`, so a thrown + /// `perform`, missing results, or failed snapshot won't deadlock future frames. + private func runSegmentation(on cgImage: CGImage) { + defer { + segLock.lock() + inFlight = false + segLock.unlock() + } + do { + try requestHandler.perform([request], on: cgImage, orientation: .up) + guard let maskPixelBuffer = request.results?.first?.pixelBuffer else { + return + } + let rawMask = CIImage(cvPixelBuffer: maskPixelBuffer) + // Snapshot to a CGImage so `lastMask` survives Vision's potential buffer reuse. + guard let maskCG = ciContext.createCGImage(rawMask, from: rawMask.extent) else { + return } + let snapshot = CIImage(cgImage: maskCG) + segLock.lock() + lastMask = snapshot + segLock.unlock() } catch { - return nil + return } } } diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index 054234cfea..94fbb71be1 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -3270,7 +3270,7 @@ PODS: - SocketRocket - stream-react-native-webrtc - Yoga - - stream-react-native-webrtc (137.1.3): + - stream-react-native-webrtc (137.2.0-alpha.5): - React-Core - StreamWebRTC (~> 137.0.54) - stream-video-react-native (1.32.3): @@ -3696,7 +3696,7 @@ SPEC CHECKSUMS: FBLazyVector: 82d1d7996af4c5850242966eb81e73f9a6dfab1e fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 0e3a9e48a838b913a3f5cadce1be93c489cfbb05 + hermes-engine: 79258df51fb2de8c52574d7678c0aeb338e65c3b RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 9da1d0cf93db23ca8b41e8efe9ae558fd9c0077f RCTRequired: 92a63c7041031a131fa5206eb082d53f95729b79 @@ -3787,7 +3787,7 @@ SPEC CHECKSUMS: stream-chat-react-native: 892a55dc716349e9c953286116a51ff410e9f1a3 stream-io-noise-cancellation-react-native: ea8ca1d50e305f2a0ffa027ff36c345aa5278237 stream-io-video-filters-react-native: 43d4e9901cf478a1340a599242226d024c2eb1a5 - stream-react-native-webrtc: 98f68f17acc6bd95b5cc417dfdc0953e0120e696 + stream-react-native-webrtc: 2ed7070daa90b04457810a8e4aa955d938b7c030 stream-video-react-native: 177794d3bf97980312b57959dea422b80e6576e5 StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: 57bd35729bcc46b008de4e741a5b23ac28b8854d @@ -3797,4 +3797,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0a22ee65b5bc47bc9d8a62deb3ee46f06752313f -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index ee74c6226d..3bbe3b24de 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -22,7 +22,7 @@ "@react-navigation/native-stack": "^7.3.27", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.1.3", + "@stream-io/react-native-webrtc": "137.2.0-alpha.5", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.12.2", diff --git a/yarn.lock b/yarn.lock index 071756f631..8ad26ad603 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8514,6 +8514,18 @@ __metadata: languageName: node linkType: hard +"@stream-io/react-native-webrtc@npm:137.2.0-alpha.5": + version: 137.2.0-alpha.5 + resolution: "@stream-io/react-native-webrtc@npm:137.2.0-alpha.5" + dependencies: + base64-js: "npm:1.5.1" + debug: "npm:4.3.4" + peerDependencies: + react-native: ">=0.73.0" + checksum: 10/8622dfcba51226477116749836d6b9f674a0a1cb74c2f3758a3c5038552c7209e3478cf24c6cb4ee949ad6399bc719349906aa029078482b2bc15a2440a04c4e + languageName: node + linkType: hard + "@stream-io/stream-video-react-tutorial@workspace:sample-apps/react/stream-video-react-tutorial": version: 0.0.0-use.local resolution: "@stream-io/stream-video-react-tutorial@workspace:sample-apps/react/stream-video-react-tutorial" @@ -8742,7 +8754,7 @@ __metadata: "@rnx-kit/metro-resolver-symlinks": "npm:^0.2.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.1.3" + "@stream-io/react-native-webrtc": "npm:137.2.0-alpha.5" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.2.0" @@ -8840,7 +8852,7 @@ __metadata: "@react-native/babel-preset": "npm:^0.81.5" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.1.3" + "@stream-io/react-native-webrtc": "npm:137.2.0-alpha.5" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-bindings": "workspace:*" From 0fd3b49c1506f4c02c780b463812f8229ee27b83 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 11:47:59 +0200 Subject: [PATCH 10/15] fix: bounded timeout for remote urls in iOS --- .../ImageBackgroundVideoFrameProcessor.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift index 0a9b56345e..c699052eeb 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift @@ -76,7 +76,19 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { if let url = URL(string: backgroundImageUrl) { bgUIImage = RCTImageFromLocalAssetURL(url) if bgUIImage == nil { - if let data = try? Data(contentsOf: url) { + // Bounded timeout (matches Android's 10s) so a hanging remote URL + // doesn't keep this background thread alive for the OS-default ~75s. + var request = URLRequest(url: url) + request.timeoutInterval = 10 + let semaphore = DispatchSemaphore(value: 0) + var fetchedData: Data? + let task = URLSession.shared.dataTask(with: request) { data, _, _ in + fetchedData = data + semaphore.signal() + } + task.resume() + semaphore.wait() + if let data = fetchedData { bgUIImage = UIImage(data: data) } else { // URLs may carry signed-access query tokens; log only the host. From a037add3152b6d5d40bbe64f7d62564124a2106a Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 12:02:33 +0200 Subject: [PATCH 11/15] code-rabbit review --- .../ImageBackgroundVideoFrameProcessor.swift | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift index c699052eeb..cd22bffbdf 100644 --- a/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift +++ b/packages/video-filters-react-native/ios/VideoFrameProcessors/ImageBackgroundVideoFrameProcessor.swift @@ -34,6 +34,7 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { // NSLock because the load thread writes it and the capture thread reads it. private let backgroundImageLock = NSLock() private var _backgroundCIImage: CIImage? + private var backgroundImageTask: URLSessionDataTask? private var backgroundCIImage: CIImage? { backgroundImageLock.lock() @@ -72,36 +73,40 @@ final class ImageBackgroundVideoFrameProcessor: VideoFilter { } private func loadBackgroundImage() { - var bgUIImage: UIImage? - if let url = URL(string: backgroundImageUrl) { - bgUIImage = RCTImageFromLocalAssetURL(url) - if bgUIImage == nil { - // Bounded timeout (matches Android's 10s) so a hanging remote URL - // doesn't keep this background thread alive for the OS-default ~75s. - var request = URLRequest(url: url) - request.timeoutInterval = 10 - let semaphore = DispatchSemaphore(value: 0) - var fetchedData: Data? - let task = URLSession.shared.dataTask(with: request) { data, _, _ in - fetchedData = data - semaphore.signal() - } - task.resume() - semaphore.wait() - if let data = fetchedData { - bgUIImage = UIImage(data: data) - } else { - // URLs may carry signed-access query tokens; log only the host. - let host = url.host ?? "local" - NSLog("Failed to load virtual-background image (host=\(host))") - } + guard let url = URL(string: backgroundImageUrl) else { return } + if let bgUIImage = RCTImageFromLocalAssetURL(url) { + setBackgroundImage(bgUIImage) + return + } + // Bounded timeout (matches Android's 10s) so a hanging remote URL + // doesn't keep this processor alive for the OS-default ~75s. + var request = URLRequest(url: url) + request.timeoutInterval = 10 + let task = URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in + // `[weak self]`: if the processor is released while the task is in flight + // (deinit calls cancel()), the closure no-ops and the task is freed. + guard let self = self else { return } + guard let data = data, let bgUIImage = UIImage(data: data) else { + // URLs may carry signed-access query tokens; log only the host. + let host = url.host ?? "local" + NSLog("Failed to load virtual-background image (host=\(host))") + return } + self.setBackgroundImage(bgUIImage) } - guard let bgUIImage = bgUIImage else { return } + backgroundImageTask = task + task.resume() + } + + private func setBackgroundImage(_ image: UIImage) { backgroundImageLock.lock() - _backgroundCIImage = CIImage(image: bgUIImage) + _backgroundCIImage = CIImage(image: image) backgroundImageLock.unlock() } + + deinit { + backgroundImageTask?.cancel() + } /// Returns the cached or processed background image for a given original image (frame image). private func backgroundImage(image: CIImage, originalImage: CIImage, originalImageOrientation: CGImagePropertyOrientation) -> CIImage { From bf8ceaa935997065cb721d9e8e0d918565cd5662 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 12:32:03 +0200 Subject: [PATCH 12/15] JS state reset on call change --- packages/react-native-sdk/src/contexts/BackgroundFilters.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx index 67c2130edf..e2461d2baa 100644 --- a/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx +++ b/packages/react-native-sdk/src/contexts/BackgroundFilters.tsx @@ -211,6 +211,7 @@ export const BackgroundFiltersProvider = ({ children }: PropsWithChildren) => { isBackgroundBlurRegisteredRef.current = false; isVideoBlurRegisteredRef.current = false; registeredImageFiltersSet.clear(); + setCurrentBackgroundFilter(undefined); }; }, [call]); From da40bebb28a550c73c98924de78e52dfb17854de Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 12:32:32 +0200 Subject: [PATCH 13/15] Android filter resource cleanup --- .../common/BitmapVideoFilter.kt | 2 ++ .../common/VideoFrameWithBitmapFilter.kt | 8 +++++--- .../factories/BackgroundBlurFactory.kt | 9 +++++++++ .../factories/VirtualBackgroundFactory.kt | 13 +++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/BitmapVideoFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/BitmapVideoFilter.kt index cd7c001f96..8cbabc9983 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/BitmapVideoFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/BitmapVideoFilter.kt @@ -8,4 +8,6 @@ import android.graphics.Bitmap */ abstract class BitmapVideoFilter { abstract fun applyFilter(videoFrameBitmap: Bitmap) + + open fun close() {} } diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt index 3b588a3818..b3f7e23b65 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/common/VideoFrameWithBitmapFilter.kt @@ -26,9 +26,8 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid private val textures = IntArray(1) private var inputFrameBitmap: Bitmap? = null - private val bitmapVideoFilter by lazy { - bitmapVideoFilterFunc.invoke() - } + private val bitmapVideoFilterLazy = lazy { bitmapVideoFilterFunc.invoke() } + private val bitmapVideoFilter: BitmapVideoFilter by bitmapVideoFilterLazy init { GLES20.glGenTextures(1, textures, 0) @@ -94,6 +93,9 @@ class VideoFrameProcessorWithBitmapFilter(bitmapVideoFilterFunc: () -> BitmapVid // Always delete the texture: glGenTextures in init can return a valid id even // before initialize() runs, so enable-then-disable would leak it otherwise. override fun dispose() { + if (bitmapVideoFilterLazy.isInitialized()) { + bitmapVideoFilter.close() + } yuvFrame.close() inputFrameBitmap?.recycle() inputFrameBitmap = null diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/BackgroundBlurFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/BackgroundBlurFactory.kt index 50b4fe0712..a75e5f9440 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/BackgroundBlurFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/BackgroundBlurFactory.kt @@ -149,6 +149,15 @@ private class BlurredBackgroundVideoFilter( blurredBackgroundBitmap.recycle() } + // Free the ML Kit segmenter and cached bitmaps held by the filter. + override fun close() { + segmenter.close() + backgroundMaskBitmap?.recycle() + backgroundMaskBitmap = null + downScaledBackgroundBitmap?.recycle() + downScaledBackgroundBitmap = null + } + private fun maybeInit(videoFrameBitmap: Bitmap, mask: SegmentationMask) { var createScale = false if (currentFrameWidth != videoFrameBitmap.width || currentFrameHeight != videoFrameBitmap.height) { diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt index 557982a200..2ac6897ecb 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt @@ -194,6 +194,19 @@ private class VirtualBackgroundVideoFilter( canvas.drawBitmap(scaledVirtualBackgroundBitmap!!, 0f, 0f, backgroundPaint) } + // Free the ML Kit segmenter and cached bitmaps held by the filter. + override fun close() { + segmenter.close() + virtualBackgroundBitmap?.recycle() + virtualBackgroundBitmap = null + scaledVirtualBackgroundBitmap?.recycle() + scaledVirtualBackgroundBitmap = null + foregroundMaskBitmap?.recycle() + foregroundMaskBitmap = null + scaledForegroundBitmap?.recycle() + scaledForegroundBitmap = null + } + private fun scaleVirtualBackgroundBitmap(bitmap: Bitmap, targetHeight: Int): Bitmap { val scale = targetHeight.toFloat() / bitmap.height return ensureAlpha( From 25dcac83f04a0dd8ad6c8919ebbd46adecbb9562 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 13:37:57 +0200 Subject: [PATCH 14/15] coderabbit review fix --- .../factories/VirtualBackgroundFactory.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt index 2ac6897ecb..c04564151c 100644 --- a/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt +++ b/packages/video-filters-react-native/android/src/main/java/com/streamio/videofiltersreactnative/factories/VirtualBackgroundFactory.kt @@ -66,12 +66,16 @@ private class VirtualBackgroundVideoFilter( @Volatile private var virtualBackgroundBitmap: Bitmap? = null + // Guards against the load thread writing virtualBackgroundBitmap after close(). + @Volatile + private var isClosed = false + init { Thread { loadBackgroundImage() }.start() } private fun loadBackgroundImage() { - virtualBackgroundBitmap = try { + val bitmap: Bitmap? = try { val uri = Uri.parse(backgroundImageUrlString) if (uri.scheme == null) { // this is a local image val drawableId = ResourceDrawableIdHelper.getInstance() @@ -92,6 +96,14 @@ private class VirtualBackgroundVideoFilter( Log.e(TAG, "cant get bitmap for image (host=$host)", e) null } + + synchronized(this) { + if (isClosed) { + bitmap?.recycle() + return + } + virtualBackgroundBitmap = bitmap + } } private val options = @@ -196,9 +208,12 @@ private class VirtualBackgroundVideoFilter( // Free the ML Kit segmenter and cached bitmaps held by the filter. override fun close() { + synchronized(this) { + isClosed = true + virtualBackgroundBitmap?.recycle() + virtualBackgroundBitmap = null + } segmenter.close() - virtualBackgroundBitmap?.recycle() - virtualBackgroundBitmap = null scaledVirtualBackgroundBitmap?.recycle() scaledVirtualBackgroundBitmap = null foregroundMaskBitmap?.recycle() From 14ecbfbc320a8e8c369a9d6978b813f44b5e3a24 Mon Sep 17 00:00:00 2001 From: Santhosh Vaiyapuri Date: Tue, 28 Apr 2026 13:48:08 +0200 Subject: [PATCH 15/15] version updates --- packages/react-native-sdk/package.json | 4 ++-- .../video-filters-react-native/package.json | 2 +- .../react-native/dogfood/ios/Podfile.lock | 4 ++-- sample-apps/react-native/dogfood/package.json | 2 +- .../expo-video-sample/package.json | 2 +- .../ringing-tutorial/package.json | 2 +- yarn.lock | 20 +++++++++---------- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index f61e89c7de..4602a1c0e5 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -65,7 +65,7 @@ "@react-native-firebase/messaging": ">=17.5.0", "@stream-io/noise-cancellation-react-native": ">=0.1.0", "@stream-io/react-native-callingx": ">=0.1.0", - "@stream-io/react-native-webrtc": ">=137.1.3", + "@stream-io/react-native-webrtc": ">=137.2.0", "@stream-io/video-filters-react-native": ">=0.1.0", "expo": ">=47.0.0", "expo-build-properties": "*", @@ -127,7 +127,7 @@ "@react-native/babel-preset": "^0.81.5", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.2.0-alpha.5", + "@stream-io/react-native-webrtc": "137.2.0", "@stream-io/video-filters-react-native": "workspace:^", "@testing-library/jest-native": "^5.4.3", "@testing-library/react-native": "13.3.3", diff --git a/packages/video-filters-react-native/package.json b/packages/video-filters-react-native/package.json index 94d1952914..700283f1f3 100644 --- a/packages/video-filters-react-native/package.json +++ b/packages/video-filters-react-native/package.json @@ -56,7 +56,7 @@ "typescript": "^5.9.3" }, "peerDependencies": { - "@stream-io/react-native-webrtc": ">=137.1.2", + "@stream-io/react-native-webrtc": ">=137.2.0", "react-native": "*" }, "react-native-builder-bob": { diff --git a/sample-apps/react-native/dogfood/ios/Podfile.lock b/sample-apps/react-native/dogfood/ios/Podfile.lock index 94fbb71be1..b69565a93c 100644 --- a/sample-apps/react-native/dogfood/ios/Podfile.lock +++ b/sample-apps/react-native/dogfood/ios/Podfile.lock @@ -3270,7 +3270,7 @@ PODS: - SocketRocket - stream-react-native-webrtc - Yoga - - stream-react-native-webrtc (137.2.0-alpha.5): + - stream-react-native-webrtc (137.2.0): - React-Core - StreamWebRTC (~> 137.0.54) - stream-video-react-native (1.32.3): @@ -3787,7 +3787,7 @@ SPEC CHECKSUMS: stream-chat-react-native: 892a55dc716349e9c953286116a51ff410e9f1a3 stream-io-noise-cancellation-react-native: ea8ca1d50e305f2a0ffa027ff36c345aa5278237 stream-io-video-filters-react-native: 43d4e9901cf478a1340a599242226d024c2eb1a5 - stream-react-native-webrtc: 2ed7070daa90b04457810a8e4aa955d938b7c030 + stream-react-native-webrtc: d5e9e2bfdff70415d153b9ad8e6f1cb7aa3bbb0e stream-video-react-native: 177794d3bf97980312b57959dea422b80e6576e5 StreamVideoNoiseCancellation: 41f5a712aba288f9636b64b17ebfbdff52c61490 StreamWebRTC: 57bd35729bcc46b008de4e741a5b23ac28b8854d diff --git a/sample-apps/react-native/dogfood/package.json b/sample-apps/react-native/dogfood/package.json index 3bbe3b24de..fcaa464f17 100644 --- a/sample-apps/react-native/dogfood/package.json +++ b/sample-apps/react-native/dogfood/package.json @@ -22,7 +22,7 @@ "@react-navigation/native-stack": "^7.3.27", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.2.0-alpha.5", + "@stream-io/react-native-webrtc": "137.2.0", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "axios": "^1.12.2", diff --git a/sample-apps/react-native/expo-video-sample/package.json b/sample-apps/react-native/expo-video-sample/package.json index b1ec62af65..329fa811df 100644 --- a/sample-apps/react-native/expo-video-sample/package.json +++ b/sample-apps/react-native/expo-video-sample/package.json @@ -20,7 +20,7 @@ "@react-native-firebase/messaging": "~23.7.0", "@stream-io/noise-cancellation-react-native": "workspace:^", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.1.3", + "@stream-io/react-native-webrtc": "137.2.0", "@stream-io/video-filters-react-native": "workspace:^", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "^54.0.12", diff --git a/sample-apps/react-native/ringing-tutorial/package.json b/sample-apps/react-native/ringing-tutorial/package.json index 752934dfb9..c40e0980f2 100644 --- a/sample-apps/react-native/ringing-tutorial/package.json +++ b/sample-apps/react-native/ringing-tutorial/package.json @@ -22,7 +22,7 @@ "@react-navigation/bottom-tabs": "^7.4.8", "@react-navigation/native": "^7.1.18", "@stream-io/react-native-callingx": "workspace:^", - "@stream-io/react-native-webrtc": "137.1.3", + "@stream-io/react-native-webrtc": "137.2.0", "@stream-io/video-react-native-sdk": "workspace:^", "expo": "^55.0.0", "expo-blur": "~55.0.10", diff --git a/yarn.lock b/yarn.lock index 8ad26ad603..63c09f2a50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8364,7 +8364,7 @@ __metadata: "@rnx-kit/metro-resolver-symlinks": "npm:^0.2.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.1.3" + "@stream-io/react-native-webrtc": "npm:137.2.0" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:~19.1.17" @@ -8514,15 +8514,15 @@ __metadata: languageName: node linkType: hard -"@stream-io/react-native-webrtc@npm:137.2.0-alpha.5": - version: 137.2.0-alpha.5 - resolution: "@stream-io/react-native-webrtc@npm:137.2.0-alpha.5" +"@stream-io/react-native-webrtc@npm:137.2.0": + version: 137.2.0 + resolution: "@stream-io/react-native-webrtc@npm:137.2.0" dependencies: base64-js: "npm:1.5.1" debug: "npm:4.3.4" peerDependencies: react-native: ">=0.73.0" - checksum: 10/8622dfcba51226477116749836d6b9f674a0a1cb74c2f3758a3c5038552c7209e3478cf24c6cb4ee949ad6399bc719349906aa029078482b2bc15a2440a04c4e + checksum: 10/d9aa189f0602ee4a6d503d735345af846647f0265df8a5ed5f54bc1f034536d294ed0ab8ec2af30bacc73c1b7526d5fca6da539648b938ce33ea97d058a279be languageName: node linkType: hard @@ -8619,7 +8619,7 @@ __metadata: rimraf: "npm:^6.0.1" typescript: "npm:^5.9.3" peerDependencies: - "@stream-io/react-native-webrtc": ">=137.1.2" + "@stream-io/react-native-webrtc": ">=137.2.0" react-native: "*" languageName: unknown linkType: soft @@ -8754,7 +8754,7 @@ __metadata: "@rnx-kit/metro-resolver-symlinks": "npm:^0.2.6" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.2.0-alpha.5" + "@stream-io/react-native-webrtc": "npm:137.2.0" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:^19.2.0" @@ -8804,7 +8804,7 @@ __metadata: "@rnx-kit/metro-config": "npm:^2.1.2" "@rnx-kit/metro-resolver-symlinks": "npm:^0.2.6" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.1.3" + "@stream-io/react-native-webrtc": "npm:137.2.0" "@stream-io/video-react-native-sdk": "workspace:^" "@types/react": "npm:~19.2.10" expo: "npm:^55.0.0" @@ -8852,7 +8852,7 @@ __metadata: "@react-native/babel-preset": "npm:^0.81.5" "@stream-io/noise-cancellation-react-native": "workspace:^" "@stream-io/react-native-callingx": "workspace:^" - "@stream-io/react-native-webrtc": "npm:137.2.0-alpha.5" + "@stream-io/react-native-webrtc": "npm:137.2.0" "@stream-io/video-client": "workspace:*" "@stream-io/video-filters-react-native": "workspace:^" "@stream-io/video-react-bindings": "workspace:*" @@ -8889,7 +8889,7 @@ __metadata: "@react-native-firebase/messaging": ">=17.5.0" "@stream-io/noise-cancellation-react-native": ">=0.1.0" "@stream-io/react-native-callingx": ">=0.1.0" - "@stream-io/react-native-webrtc": ">=137.1.3" + "@stream-io/react-native-webrtc": ">=137.2.0" "@stream-io/video-filters-react-native": ">=0.1.0" expo: ">=47.0.0" expo-build-properties: "*"