Skip to content

feat: handle lifecycle on android video effects#34

Merged
santhoshvai merged 5 commits intomasterfrom
video-effect-android-dispose
Apr 28, 2026
Merged

feat: handle lifecycle on android video effects#34
santhoshvai merged 5 commits intomasterfrom
video-effect-android-dispose

Conversation

@santhoshvai
Copy link
Copy Markdown
Member

@santhoshvai santhoshvai commented Apr 24, 2026

Add lifecycle to video-effect processors on Android

VideoFrameProcessor implementations that hold native or GL resources had no way to release them: VideoEffectProcessor was only ever replaced or dropped, never torn down. Long-running sessions that switched filters leaked every previous processor's resources.

Changes

  • VideoFrameProcessor: new default void dispose() — default no-op keeps existing implementations compiling.
  • VideoEffectProcessor: owns a dispose() that iterates its wrapped processors. Posted to the capturer handler so it's serialized with onFrameCaptured; idempotent.
  • GetUserMediaImpl:
    • setVideoEffects now disposes the previous processor after swapping in the new one (swap first, dispose last — a frame in flight must not hit a freed processor).
    • TrackPrivate.dispose disposes the attached processor after stopCapture() returns and before SurfaceTextureHelper is disposed (GL context still alive).

Why not wire it to VideoProcessor.onCapturerStopped? That also fires on temporary pauses (camera disable / backgrounding) where the same processor is reused on resume — we'd dispose state the processor still needs.

Test plan

  • Switch filters repeatedly on a live call — no leaks, no crashes.
  • Background/foreground the app mid-call — filter keeps working on resume (no accidental dispose).
  • End the call / dispose the track — downstream processors' dispose() is called exactly once.

Summary by CodeRabbit

  • Bug Fixes
    • Improved video effects lifecycle and cleanup to prevent memory leaks and improve stability during video processing.
    • Camera selection now respects updated deviceId/facingMode constraints at apply time, enabling more reliable camera switching when constraints change.
  • Chores
    • Package version bumped to 137.2.0-alpha.5.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

The changes add explicit lifecycle management for video effect processors: a new dispose() hook on frame processors and an asynchronous, idempotent dispose() on VideoEffectProcessor; TrackPrivate now tracks the active processor and disposes it during swaps and teardown to ensure GL resources are released.

Changes

Cohort / File(s) Summary
Processor Lifecycle Infrastructure
android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoFrameProcessor.java, android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoEffectProcessor.java
Added default void dispose() to VideoFrameProcessor. Added public void dispose() to VideoEffectProcessor that runs on the SurfaceTextureHelper handler, is idempotent, and disposes wrapped frame processors after any in-flight work.
Track lifecycle and effect wiring
android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java
Added public VideoEffectProcessor videoEffectProcessor; to TrackPrivate. setVideoEffects now clears previous track reference before calling videoSource.setVideoProcessor(...), assigns the new processor to track.videoEffectProcessor, and disposes the prior processor (null-checked) after the swap. TrackPrivate.dispose() now disposes and nulls the processor during non-clone teardown after stopCapture().
Camera constraint handling (Android)
android/src/main/java/com/oney/WebRTCModule/CameraCaptureController.java
applyConstraints(...) now derives deviceId/facingMode from the provided constraints argument instead of stored fields, enabling runtime-facing device selection based on supplied constraints.
Camera constraint handling (iOS)
ios/RCTWebRTC/VideoCaptureController.m
applyConstraints now maps facingMode to front/back selection, updates usingFrontCamera and clears or updates deviceId when device-selection constraints change to force appropriate camera restarts.
Version bump
package.json
Package version incremented from 137.2.0-alpha.3 to 137.2.0-alpha.5.

Sequence Diagram(s)

sequenceDiagram
    actor Caller
    participant TrackPrivate as TrackPrivate
    participant VideoSource as VideoSource
    participant VideoEffectProcessor as VideoEffectProcessor
    participant ST_Helper as SurfaceTextureHelper
    participant FrameProcessor as VideoFrameProcessor

    rect rgba(200,230,255,0.5)
    Caller->>TrackPrivate: setVideoEffects(newEffects)
    TrackPrivate->>VideoSource: setVideoProcessor(newProcessor)
    VideoSource->>VideoEffectProcessor: create/attach(newProcessor)
    end

    rect rgba(200,255,220,0.5)
    Note over TrackPrivate,VideoEffectProcessor: swap active processor reference
    TrackPrivate->>TrackPrivate: clear previous videoEffectProcessor ref
    TrackPrivate->>VideoEffectProcessor: assign new videoEffectProcessor
    end

    rect rgba(255,230,200,0.5)
    Note over VideoEffectProcessor,ST_Helper: dispose previous processor asynchronously
    TrackPrivate->>ST_Helper: enqueue dispose(previousProcessor)
    ST_Helper->>FrameProcessor: invoke dispose() on wrapped processors
    FrameProcessor-->>ST_Helper: disposed
    ST_Helper-->>TrackPrivate: dispose completed
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I swapped the masks and bowed my head,

Disposed the ghosts the frames once fed,
GL lights dim, the pipeline sleeps,
Processors rest in tidy heaps,
A rabbit nods — no leaks ahead. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding lifecycle management for Android video effects processors, which aligns with the core objective of preventing resource leaks.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch video-effect-android-dispose

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java (1)

580-620: ⚠️ Potential issue | 🟡 Minor

Minor: clone + setVideoEffects can leak a processor on teardown.

setVideoEffects only gates on videoCaptureController instanceof CameraCaptureController, which clones satisfy (they share the controller). If a caller invokes setVideoEffects(cloneId, …):

  • The new processor is attached to the shared videoSource and stored on clone.videoEffectProcessor.
  • The original's videoEffectProcessor field still references the previously-installed processor — now detached from the source but correctly disposed when the original is torn down.
  • On clone teardown, TrackPrivate.dispose() skips disposal because of !isClone, so the processor actually attached to the shared source is never dispose()d.

If this path is supported, consider either storing/disposing the processor on the parent track or short-circuiting setVideoEffects on clones.

🔧 Option A — route processor ownership to the parent (sketch)
 void setVideoEffects(String trackId, ReadableArray names) {
     TrackPrivate track = tracks.get(trackId);

     if (track != null && track.videoCaptureController instanceof CameraCaptureController) {
+        TrackPrivate owner = track.isClone() ? track.getParent() : track;
         VideoSource videoSource = (VideoSource) track.mediaSource;
         SurfaceTextureHelper surfaceTextureHelper = track.surfaceTextureHelper;

-        VideoEffectProcessor previousProcessor = track.videoEffectProcessor;
-        track.videoEffectProcessor = null;
+        VideoEffectProcessor previousProcessor = owner.videoEffectProcessor;
+        owner.videoEffectProcessor = null;
         ...
-            track.videoEffectProcessor = videoEffectProcessor;
+            owner.videoEffectProcessor = videoEffectProcessor;
         ...
     }
 }

(Requires exposing getParent() on TrackPrivate.)

🔧 Option B — reject on clones
         if (track != null && track.videoCaptureController instanceof CameraCaptureController) {
+            if (track.isClone()) {
+                Log.w(TAG, "setVideoEffects called on a cloned track; ignoring.");
+                return;
+            }

Also applies to: 700-714

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java` around
lines 580 - 620, setVideoEffects can leak processors when called on a clone
because clones share the same CameraCaptureController/videoSource but keep
separate videoEffectProcessor fields; to fix, early-return for clones in
setVideoEffects: detect clone state on TrackPrivate (e.g., track.isClone() or
track.getParent() != null) and if true log and return without attaching a new
processor, so only the parent owns and disposes processors; ensure you reference
TrackPrivate.videoEffectProcessor and VideoSource.setVideoProcessor when making
the change so the shared videoSource isn't given a processor from a clone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java`:
- Around line 580-620: setVideoEffects can leak processors when called on a
clone because clones share the same CameraCaptureController/videoSource but keep
separate videoEffectProcessor fields; to fix, early-return for clones in
setVideoEffects: detect clone state on TrackPrivate (e.g., track.isClone() or
track.getParent() != null) and if true log and return without attaching a new
processor, so only the parent owns and disposes processors; ensure you reference
TrackPrivate.videoEffectProcessor and VideoSource.setVideoProcessor when making
the change so the shared videoSource isn't given a processor from a clone.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f535ce15-ee9a-46ea-bc5e-a4e800b832ed

📥 Commits

Reviewing files that changed from the base of the PR and between 43f91de and e84ca84.

📒 Files selected for processing (3)
  • android/src/main/java/com/oney/WebRTCModule/GetUserMediaImpl.java
  • android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoEffectProcessor.java
  • android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoFrameProcessor.java

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/src/main/java/com/oney/WebRTCModule/CameraCaptureController.java (1)

122-151: ⚠️ Potential issue | 🟠 Major

Missing facingMode/deviceId now forces a front-camera switch.

ReactBridgeUtil.getMapStrValue returns null when the key is absent, and per the primary callers (WebRTCModule.mediaStreamTrackApplyConstraintsGetUserMediaImpl.applyConstraints) the constraints map is forwarded from JS as-is — callers routinely send resolution/fps-only updates without deviceId or facingMode. In that case, deviceId == null and facingMode == null, so the fallback at Line 143 (isFrontFacing = facingMode == null || facingMode.equals("user")) evaluates to true and the code will pick the front camera, silently switching away from a currently-active back camera on any plain resolution change.

This is also inconsistent with the matching iOS change in this PR: VideoCaptureController.m (Lines 170, 171–177) seeds targetFront = self.usingFrontCamera and only flips it when facingMode is explicitly "user"/"environment", preserving current state otherwise.

Suggest either short-circuiting when neither selector is present, or defaulting to the current device/facing:

🐛 Proposed fix: preserve current camera when selectors are absent
         // Re-read from the incoming constraints so `MediaStreamTrack._switchCamera()`
         // can flip the camera via `applyConstraints({facingMode})` — the documented
         // W3C pattern that browsers also implement.
         final String deviceId = ReactBridgeUtil.getMapStrValue(constraints, "deviceId");
         final String facingMode = ReactBridgeUtil.getMapStrValue(constraints, "facingMode");
         int cameraIndex = -1;
         String cameraName = null;

         // If deviceId is specified, then it takes precedence over facingMode.
         if (deviceId != null) {
             try {
                 cameraIndex = Integer.parseInt(deviceId);
                 cameraName = deviceNames[cameraIndex];
             } catch (Exception e) {
                 Log.d(TAG, "failed to find device with id: " + deviceId);
             }
         }

-        // Otherwise, use facingMode (defaulting to front/user facing).
-        if (cameraName == null) {
+        // Otherwise, use facingMode, preserving the current facing when unspecified
+        // so resolution/fps-only updates don't force a camera switch.
+        if (cameraName == null && (deviceId != null || facingMode != null)) {
             cameraIndex = -1;
-            final boolean isFrontFacing = facingMode == null || facingMode.equals("user");
+            final boolean isFrontFacing =
+                    facingMode == null ? this.isFrontFacing : facingMode.equals("user");
             for (String name : deviceNames) {
                 cameraIndex++;
                 if (cameraEnumerator.isFrontFacing(name) == isFrontFacing) {
                     cameraName = name;
                     break;
                 }
             }
         }
+
+        if (cameraName == null && deviceId == null && facingMode == null) {
+            // No device-selection change requested; keep the current camera.
+            try {
+                cameraIndex = Integer.parseInt(currentDeviceId);
+                cameraName = deviceNames[cameraIndex];
+            } catch (Exception e) {
+                // Fall through to the OverconstrainedError path below.
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/src/main/java/com/oney/WebRTCModule/CameraCaptureController.java`
around lines 122 - 151, The code in CameraCaptureController reads deviceId and
facingMode from constraints and treats a null facingMode as "user", which causes
an unintended switch to the front camera when callers send constraints that omit
both selectors; update the logic in CameraCaptureController so that when both
deviceId and facingMode are absent you preserve the current camera instead of
selecting a default: detect the case where
ReactBridgeUtil.getMapStrValue(constraints, "deviceId") == null and
ReactBridgeUtil.getMapStrValue(constraints, "facingMode") == null and
short-circuit so cameraIndex/cameraName remain set to the currently-active
device (or skip the for-loop that picks based on
cameraEnumerator.isFrontFacing), otherwise keep the existing precedence
(deviceId wins, then explicit facingMode handling). Ensure you reference and
preserve the existing variables cameraIndex, cameraName, deviceNames and
cameraEnumerator when implementing the short-circuit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@android/src/main/java/com/oney/WebRTCModule/CameraCaptureController.java`:
- Around line 122-151: The code in CameraCaptureController reads deviceId and
facingMode from constraints and treats a null facingMode as "user", which causes
an unintended switch to the front camera when callers send constraints that omit
both selectors; update the logic in CameraCaptureController so that when both
deviceId and facingMode are absent you preserve the current camera instead of
selecting a default: detect the case where
ReactBridgeUtil.getMapStrValue(constraints, "deviceId") == null and
ReactBridgeUtil.getMapStrValue(constraints, "facingMode") == null and
short-circuit so cameraIndex/cameraName remain set to the currently-active
device (or skip the for-loop that picks based on
cameraEnumerator.isFrontFacing), otherwise keep the existing precedence
(deviceId wins, then explicit facingMode handling). Ensure you reference and
preserve the existing variables cameraIndex, cameraName, deviceNames and
cameraEnumerator when implementing the short-circuit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 286eb9ec-8324-4881-bba6-1a3e1238229a

📥 Commits

Reviewing files that changed from the base of the PR and between e84ca84 and 4adccff.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (4)
  • android/src/main/java/com/oney/WebRTCModule/CameraCaptureController.java
  • android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoEffectProcessor.java
  • ios/RCTWebRTC/VideoCaptureController.m
  • package.json
✅ Files skipped from review due to trivial changes (1)
  • package.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • android/src/main/java/com/oney/WebRTCModule/videoEffects/VideoEffectProcessor.java

@santhoshvai santhoshvai merged commit 042077c into master Apr 28, 2026
4 checks passed
@santhoshvai santhoshvai deleted the video-effect-android-dispose branch April 28, 2026 11:13
santhoshvai added a commit to GetStream/stream-video-js that referenced this pull request Apr 28, 2026
### 💡 Overview

Correctness, perf, and cleanup fixes in the React Native
background-filter pipeline.

  ### 📝 Implementation notes

- **iOS**: fix blur background-drift (CIGaussianBlur extent); run Vision
segmentation at ~540p (2–4× faster on mid-tier chips); close retain
cycle in filter closures and `NotificationCenter` observer leak.
- **iOS segmentation async**: run Vision off the capture thread on a
dedicated serial queue; composite using the last completed mask
(≤1-frame staleness, imperceptible). Mirrors Android's ML Kit
pattern. Unblocks the capture thread so preview stays at 30 fps on older
chips (e.g. iPhone XS) where Vision was previously eating most of the
frame budget.
- **Android**: per-filter `YuvFrame` instance (fixes camera-flip race);
GL/libyuv resource cleanup via a new `VideoFrameProcessor.dispose()`
lifecycle hook.
- **Remote URL backgrounds**: loaded off-thread on both platforms with
timeouts; host-only error logging.
- **SDK provider**: reapply on track replacement (camera flip);
staleness guard for in-flight apply calls; `unregisterAllFilters()` on
unmount so native processors can deallocate.

Requires the paired [react-native-webrtc
PR](GetStream/react-native-webrtc#34) for the
`dispose()` hook and a camera-flip regression fix.

🎫 Ticket: https://linear.app/stream/issue/XYZ-123

📑 Docs: https://github.com/GetStream/docs-content/pull/<id>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an "unregister all filters" control to release previously
registered filters.

* **Bug Fixes**
* Prevents stale/overlapping filter changes and reapplies effects when
media tracks update.
* Improved teardown to reliably remove filters and clear cached
resources.

* **Performance Improvements**
  * Background images load asynchronously to avoid UI blocking.
  * Blur processing downsamples for more efficient rendering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant