Skip to content

Comments

Improve video filter pipeline Pipeline by stabilizing bitmap lifecycle#1600

Merged
aleksandar-apostolov merged 8 commits intodevelopfrom
bugfix/rahullohra/black-frame
Jan 20, 2026
Merged

Improve video filter pipeline Pipeline by stabilizing bitmap lifecycle#1600
aleksandar-apostolov merged 8 commits intodevelopfrom
bugfix/rahullohra/black-frame

Conversation

@rahul-lohra
Copy link
Contributor

@rahul-lohra rahul-lohra commented Jan 15, 2026

Goal

Prevent beginning & intermittent black frames when video filters are enabled by ensuring bitmap resources are not recycled or recreated during adaptive resolution changes. This change makes the video filter pipeline resilient to dynamic frame size updates caused by adaptive streaming, eliminating visible flicker during filter activation and resolution switches.

Implementation

Problem
When video filters are enabled, FilterVideoProcessor converts incoming frames to a Bitmap for bitmap-based filters.
Previously, whenever the frame width or height changed (which can happen frequently due to adaptive streaming), the pipeline:

  • Recycled the existing bitmap
  • Allocated a new bitmap
  • Initialise OpenGL Resources

During this transition, we lost the original bitmap and as result we rendered a black bitmap

Solution

  • Only initialise OpenGL Resources

🎨 UI Changes

None

Testing

Apply a filter and there should no black frame

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling and logging in video frame conversion processes.
  • Performance Improvements

    • Optimized video frame processing and resource management for more efficient bitmap handling and GL resource initialization.
    • Streamlined YUV to bitmap conversion workflow with better resource cleanup.

✏️ Tip: You can customize this high-level summary in your review settings.

@rahul-lohra rahul-lohra self-assigned this Jan 15, 2026
@rahul-lohra rahul-lohra requested a review from a team as a code owner January 15, 2026 08:37
@rahul-lohra rahul-lohra added the pr:improvement Enhances an existing feature or code label Jan 15, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Jan 15, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@rahul-lohra rahul-lohra added pr:bug Fixes a bug and removed pr:improvement Enhances an existing feature or code labels Jan 15, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Jan 15, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 11.94 MB 11.94 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.28 MB 6.27 MB -0.02 MB 🚀

@rahul-lohra rahul-lohra changed the title [AND-921] Improve video filter pipeline Pipeline by stabilizing bitmap lifecycle Improve video filter pipeline Pipeline by stabilizing bitmap lifecycle Jan 15, 2026
@aleksandar-apostolov
Copy link
Contributor

Why is this PR towards develop-v2?

@rahul-lohra rahul-lohra changed the base branch from develop-v2 to develop January 19, 2026 08:56
@rahul-lohra
Copy link
Contributor Author

Why is this PR towards develop-v2?

Fixed—now pointing to develop. @aleksandar-apostolov

@coderabbitai
Copy link

coderabbitai bot commented Jan 20, 2026

Walkthrough

The changes refactor video frame processing to improve resource initialization and lifecycle management. FilterVideoProcessor now uses initializeGLResources() instead of initialize() and defers bitmap recycling to after frame emission. YuvFrame's bitmapFromVideoFrame method is rewritten to use direct I420 wrapping and rotation with more contained buffer handling.

Changes

Cohort / File(s) Summary
Video Processing Resource Lifecycle
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt
Replaced initialize() initialization entry point with initializeGLResources(); deferred bitmap creation and post-emission recycling; updated texture handling to use passed textureHelper parameter; adjusted imports for getValue and get utilities.
YUV Frame Conversion Pipeline
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt
Rewrote bitmapFromVideoFrame to perform direct I420 wrapping, rotation, and ABGR conversion in streamlined flow; introduced private rotateI420 helper; replaced mutable buffer fields with localized resource approach; improved error logging.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Hops through pixels, sleek and bright,
GL resources initialized right,
YUV flows in streamlined grace,
Bitmaps recycled, freed from space,
Texture handlers guide the way,
Video frames dance and play! 🎬

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Improve video filter pipeline Pipeline by stabilizing bitmap lifecycle' clearly identifies the main change—stabilizing bitmap lifecycle in the video filter pipeline to prevent black frames.
Description check ✅ Passed The PR description includes Goal and Implementation sections explaining the problem and solution, but lacks UI Changes (correctly marked as None), Testing details, and required contributor checklist items.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

@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.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt`:
- Around line 109-126: The I420Buffer created via I420Buffer.wrap (referred to
as libYuvI420) inside YuvFrame.bitmapFromVideoFrame is never closed, causing a
leak; modify YuvFrame.bitmapFromVideoFrame to call libYuvI420.release() before
returning in the successful (try) path and ensure that in the catch block you
release any allocated i420Buffer (and libYuvI420 if allocated) to avoid leaks;
locate the I420Buffer.wrap usage and add explicit .release() calls for
libYuvI420 and for i420Buffer in the respective try and catch branches so both
ref-counted buffers are always cleaned up.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt`:
- Around line 66-71: The code can leak rotatedI420 and abgrBuffer if an
exception occurs during allocation/convert/asBitmap; wrap their lifetimes in
structured cleanup (use try/finally or Kotlin .use) so rotatedI420.close()
always runs after rotateI420(...) and abgrBuffer.close() always runs after
AbgrBuffer.allocate(...)/convertTo(...)/asBitmap(), and ensure
i420Buffer.release() is invoked in the outer finally; locate the bitmap
conversion function (e.g., bitmapFromVideoFrame / VideoFrame.buffer.toI420
usage) and add nested try/finally (or use) blocks around rotatedI420 and
abgrBuffer and a final finally that calls i420Buffer.release(), also catch/log
the throwable and return null as before.
- Around line 42-75: The try block can throw before i420Buffer.release() is
called, leaking native memory; update the conversion to ensure i420Buffer is
always released (and also close rotatedI420 and abgrBuffer if they were
allocated) by moving cleanup into a finally block or using Kotlin's
use/try-with-resources pattern: after wrapping with I420Buffer.wrap and
allocating rotatedI420/abgrBuffer (functions/classes: I420Buffer.wrap,
rotateI420, rotatedI420, AbgrBuffer.allocate, abgrBuffer, asBitmap), ensure
i420Buffer.release() is invoked in finally (and call rotatedI420.close() and
abgrBuffer.close() only if they were created) so all native resources are freed
on both success and exception paths.
🧹 Nitpick comments (2)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt (1)

28-31: TODO comment indicates architectural concern.

The TODO suggests this should be an internal class rather than object. Since YuvFrame is now stateless (no stored buffers), this change would be straightforward and would improve testability and avoid any potential threading concerns with a global singleton.

Would you like me to help refactor this from object to an internal class?

stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt (1)

23-30: Remove unused imports.

The imports androidx.core.graphics.get and kotlin.getValue are not used in this file.

♻️ Remove unused imports
-import androidx.core.graphics.get
 import org.webrtc.SurfaceTextureHelper
 import org.webrtc.TextureBufferImpl
 import org.webrtc.VideoFrame
 import org.webrtc.VideoProcessor
 import org.webrtc.VideoSink
 import org.webrtc.YuvConverter
-import kotlin.getValue

Comment on lines +109 to 126
private fun initializeGLResources(width: Int, height: Int, textureHelper: SurfaceTextureHelper) {
yuvBuffer?.release()

if (this.inputWidth != width || this.inputHeight != height) {
this.inputWidth = width
this.inputHeight = height
inputFrameBitmap?.recycle()
inputBuffer?.release()

val type = VideoFrame.TextureBuffer.Type.RGB

val matrix = Matrix()
// This is vertical flip - we need to investigate why the image is flipped vertically and
// why we need to correct it here.
matrix.preScale(1.0f, -1.0f)
val surfaceTextureHelper: SurfaceTextureHelper = textureHelper

this.inputBuffer = TextureBufferImpl(
inputWidth, inputHeight, type, textures[0], matrix, surfaceTextureHelper.handler,
inputWidth, inputHeight, type, textures[0], matrix, textureHelper.handler,
yuvConverter, null as Runnable?,
)
this.inputFrameBitmap =
Bitmap.createBitmap(this.inputWidth, this.inputHeight, Bitmap.Config.ARGB_8888)
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's find the YuvFrame class
fd -t f "YuvFrame" --extension kt --extension java

Repository: GetStream/stream-video-android

Length of output: 170


🏁 Script executed:

# Also search for references to bitmapFromVideoFrame
rg "bitmapFromVideoFrame" -t kt -t java

Repository: GetStream/stream-video-android

Length of output: 100


🏁 Script executed:

# Find the FilterVideoProcessor.kt file to see the context
fd -t f "FilterVideoProcessor" --extension kt

Repository: GetStream/stream-video-android

Length of output: 182


🌐 Web query:

libyuv I420Buffer.wrap Android WebRTC explicit close cleanup requirement

💡 Result:

Short answer: yes — you must release I420 buffers when they're ref‑counted. JavaI420Buffer.wrap just wraps direct ByteBuffers (no copy) and accepts an optional releaseCallback; the Java I420 buffer is ref‑counted and callers should call release() (or provide a releaseCallback) when finished to avoid native memory leaks. [1][2][3]

Sources:

  • JavaI420Buffer.wrap implementation (wraps direct ByteBuffers; accepts releaseCallback). [1]
  • Example usage showing i420Buffer.release() in WebRTC Android code. [2]
  • API docs noting JavaI420Buffer.wrap signature. [3]

🏁 Script executed:

cat -n stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt

Repository: GetStream/stream-video-android

Length of output: 4450


🏁 Script executed:

sed -n '109,126p' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt

Repository: GetStream/stream-video-android

Length of output: 738


🏁 Script executed:

sed -n '100,130p' stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt

Repository: GetStream/stream-video-android

Length of output: 948


Close libYuvI420 to prevent resource leak in YuvFrame.bitmapFromVideoFrame.

The method rename to initializeGLResources is appropriate and the GL resource cleanup logic is correct. However, the libYuvI420 buffer created via I420Buffer.wrap() at line 44 is never closed. This is a resource leak—I420Buffer is ref-counted and requires explicit cleanup. Additionally, in the exception path (catch block), both libYuvI420 and i420Buffer are leaked. Close libYuvI420 in the try block before returning, and ensure i420Buffer is released in the catch block.

🤖 Prompt for AI Agents
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/FilterVideoProcessor.kt`
around lines 109 - 126, The I420Buffer created via I420Buffer.wrap (referred to
as libYuvI420) inside YuvFrame.bitmapFromVideoFrame is never closed, causing a
leak; modify YuvFrame.bitmapFromVideoFrame to call libYuvI420.release() before
returning in the successful (try) path and ensure that in the catch block you
release any allocated i420Buffer (and libYuvI420 if allocated) to avoid leaks;
locate the I420Buffer.wrap usage and add explicit .release() calls for
libYuvI420 and for i420Buffer in the respective try and catch branches so both
ref-counted buffers are always cleaned up.

Comment on lines +42 to +75
try {
// Wrap WebRTC I420 planes (borrowed, read-only)
val libYuvI420 = I420Buffer.wrap(
planeY = PlanePrimitive(RowStride(i420Buffer.strideY), i420Buffer.dataY),
planeU = PlanePrimitive(RowStride(i420Buffer.strideU), i420Buffer.dataU),
planeV = PlanePrimitive(RowStride(i420Buffer.strideV), i420Buffer.dataV),
width = i420Buffer.width,
height = i420Buffer.height,
)

libYuvI420Buffer = I420Buffer.wrap(
planeY = PlanePrimitive(RowStride(webRtcI420Buffer.strideY), webRtcI420Buffer.dataY),
planeU = PlanePrimitive(RowStride(webRtcI420Buffer.strideU), webRtcI420Buffer.dataU),
planeV = PlanePrimitive(RowStride(webRtcI420Buffer.strideV), webRtcI420Buffer.dataV),
width = width,
height = height,
)
}
// Rotate into a new owned buffer
val rotatedI420 = rotateI420(
source = libYuvI420,
rotationDegrees = videoFrame.rotation,
)

private fun rotateLibYuvI420Buffer(rotationDegrees: Int) {
val width = webRtcI420Buffer.width
val height = webRtcI420Buffer.height
// Convert to ABGR (owned buffer)
val abgrBuffer = AbgrBuffer.allocate(
rotatedI420.width,
rotatedI420.height,
)

when (rotationDegrees) {
90, -270 -> changeOrientation(width, height, RotateMode.ROTATE_90) // upside down, 90
180, -180 -> keepOrientation(width, height, RotateMode.ROTATE_180) // right, 180
270, -90 -> changeOrientation(width, height, RotateMode.ROTATE_270) // upright, 270
else -> keepOrientation(width, height, RotateMode.ROTATE_0) // left, 0, default
}
}
rotatedI420.convertTo(abgrBuffer)

private fun changeOrientation(width: Int, height: Int, rotateMode: RotateMode) {
libYuvRotatedI420Buffer?.close()
libYuvRotatedI420Buffer = I420Buffer.allocate(height, width) // swapped width and height
libYuvI420Buffer.rotate(libYuvRotatedI420Buffer!!, rotateMode)
// Materialize Bitmap
val bmp = abgrBuffer.asBitmap()
rotatedI420.close()
abgrBuffer.close()
i420Buffer.release()
return bmp
} catch (t: Throwable) {
logger.e(t) { "Failed to convert VideoFrame to Bitmap" }
return null
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resource leak in error path: i420Buffer is not released on failure.

When the try block throws an exception, i420Buffer.release() (line 70) is never called. This can leak native memory. The i420Buffer should be released in a finally block or restructured to ensure cleanup.

🐛 Proposed fix using try-finally
     fun bitmapFromVideoFrame(videoFrame: VideoFrame?): Bitmap? {
         if (videoFrame == null) return null

         val i420Buffer = videoFrame.buffer.toI420() ?: return null

         try {
             // Wrap WebRTC I420 planes (borrowed, read-only)
             val libYuvI420 = I420Buffer.wrap(
                 planeY = PlanePrimitive(RowStride(i420Buffer.strideY), i420Buffer.dataY),
                 planeU = PlanePrimitive(RowStride(i420Buffer.strideU), i420Buffer.dataU),
                 planeV = PlanePrimitive(RowStride(i420Buffer.strideV), i420Buffer.dataV),
                 width = i420Buffer.width,
                 height = i420Buffer.height,
             )

             // Rotate into a new owned buffer
             val rotatedI420 = rotateI420(
                 source = libYuvI420,
                 rotationDegrees = videoFrame.rotation,
             )

             // Convert to ABGR (owned buffer)
             val abgrBuffer = AbgrBuffer.allocate(
                 rotatedI420.width,
                 rotatedI420.height,
             )

             rotatedI420.convertTo(abgrBuffer)

             // Materialize Bitmap
             val bmp = abgrBuffer.asBitmap()
             rotatedI420.close()
             abgrBuffer.close()
-            i420Buffer.release()
             return bmp
         } catch (t: Throwable) {
             logger.e(t) { "Failed to convert VideoFrame to Bitmap" }
             return null
+        } finally {
+            i420Buffer.release()
         }
     }
🤖 Prompt for AI Agents
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt`
around lines 42 - 75, The try block can throw before i420Buffer.release() is
called, leaking native memory; update the conversion to ensure i420Buffer is
always released (and also close rotatedI420 and abgrBuffer if they were
allocated) by moving cleanup into a finally block or using Kotlin's
use/try-with-resources pattern: after wrapping with I420Buffer.wrap and
allocating rotatedI420/abgrBuffer (functions/classes: I420Buffer.wrap,
rotateI420, rotatedI420, AbgrBuffer.allocate, abgrBuffer, asBitmap), ensure
i420Buffer.release() is invoked in finally (and call rotatedI420.close() and
abgrBuffer.close() only if they were created) so all native resources are freed
on both success and exception paths.

Comment on lines +66 to +71
// Materialize Bitmap
val bmp = abgrBuffer.asBitmap()
rotatedI420.close()
abgrBuffer.close()
i420Buffer.release()
return bmp
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Intermediate buffer leak on exception between allocations.

If an exception occurs after rotatedI420 is created but before it's closed (e.g., during AbgrBuffer.allocate or convertTo), the rotatedI420 buffer will leak. Similarly, if asBitmap() throws, abgrBuffer leaks. Consider using use or structured cleanup.

♻️ Suggested safer cleanup pattern
fun bitmapFromVideoFrame(videoFrame: VideoFrame?): Bitmap? {
    if (videoFrame == null) return null
    val i420Buffer = videoFrame.buffer.toI420() ?: return null

    return try {
        val libYuvI420 = I420Buffer.wrap(
            planeY = PlanePrimitive(RowStride(i420Buffer.strideY), i420Buffer.dataY),
            planeU = PlanePrimitive(RowStride(i420Buffer.strideU), i420Buffer.dataU),
            planeV = PlanePrimitive(RowStride(i420Buffer.strideV), i420Buffer.dataV),
            width = i420Buffer.width,
            height = i420Buffer.height,
        )

        val rotatedI420 = rotateI420(source = libYuvI420, rotationDegrees = videoFrame.rotation)
        try {
            val abgrBuffer = AbgrBuffer.allocate(rotatedI420.width, rotatedI420.height)
            try {
                rotatedI420.convertTo(abgrBuffer)
                abgrBuffer.asBitmap()
            } finally {
                abgrBuffer.close()
            }
        } finally {
            rotatedI420.close()
        }
    } catch (t: Throwable) {
        logger.e(t) { "Failed to convert VideoFrame to Bitmap" }
        null
    } finally {
        i420Buffer.release()
    }
}
🤖 Prompt for AI Agents
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/video/YuvFrame.kt`
around lines 66 - 71, The code can leak rotatedI420 and abgrBuffer if an
exception occurs during allocation/convert/asBitmap; wrap their lifetimes in
structured cleanup (use try/finally or Kotlin .use) so rotatedI420.close()
always runs after rotateI420(...) and abgrBuffer.close() always runs after
AbgrBuffer.allocate(...)/convertTo(...)/asBitmap(), and ensure
i420Buffer.release() is invoked in the outer finally; locate the bitmap
conversion function (e.g., bitmapFromVideoFrame / VideoFrame.buffer.toI420
usage) and add nested try/finally (or use) blocks around rotatedI420 and
abgrBuffer and a final finally that calls i420Buffer.release(), also catch/log
the throwable and return null as before.

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@aleksandar-apostolov aleksandar-apostolov merged commit deaa7cf into develop Jan 20, 2026
12 of 13 checks passed
@aleksandar-apostolov aleksandar-apostolov deleted the bugfix/rahullohra/black-frame branch January 20, 2026 09:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants