Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,10 @@ dependencies {
testImplementation("com.squareup.okhttp3:mockwebserver:$okhttpVersion")
testImplementation("com.google.dagger:hilt-android-testing:2.59.1")
testImplementation("org.robolectric:robolectric:4.16.1")

// Computer Vision - for background effects during video calls
implementation 'com.google.mediapipe:tasks-vision:0.10.26'
implementation "io.github.crow-misia.libyuv:libyuv-android:0.43.2"
}

tasks.register('installGitHooks', Copy) {
Expand Down
Binary file added app/src/main/assets/selfie_segmenter.tflite
Binary file not shown.
25 changes: 24 additions & 1 deletion app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import com.nextcloud.talk.call.ReactionAnimator
import com.nextcloud.talk.call.components.ParticipantGrid
import com.nextcloud.talk.call.components.SelfVideoView
import com.nextcloud.talk.call.components.screenshare.ScreenShareComponent
import com.nextcloud.talk.camera.BackgroundBlurFrameProcessor
import com.nextcloud.talk.camera.BlurBackgroundViewModel
import com.nextcloud.talk.camera.BlurBackgroundViewModel.BackgroundBlurOn
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.CallActivityBinding
Expand Down Expand Up @@ -185,7 +188,6 @@ import java.util.Objects
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import kotlin.String
import kotlin.math.abs

@AutoInjector(NextcloudTalkApplication::class)
Expand Down Expand Up @@ -214,6 +216,7 @@ class CallActivity : CallBaseActivity() {
var audioManager: WebRtcAudioManager? = null
var callRecordingViewModel: CallRecordingViewModel? = null
var raiseHandViewModel: RaiseHandViewModel? = null
val blurBackgroundViewModel: BlurBackgroundViewModel = BlurBackgroundViewModel()
private var mReceiver: BroadcastReceiver? = null
private var peerConnectionFactory: PeerConnectionFactory? = null
private var screenSharePeerConnectionFactory: PeerConnectionFactory? = null
Expand Down Expand Up @@ -539,6 +542,20 @@ class CallActivity : CallBaseActivity() {
}
}

private fun initBackgroundBlurViewModel(surfaceTextureHelper: SurfaceTextureHelper) {
blurBackgroundViewModel.viewState.observe(this) { state ->
val isOn = state == BackgroundBlurOn

val processor = if (isOn) {
BackgroundBlurFrameProcessor(context, surfaceTextureHelper)
} else {
null
}

videoSource?.setVideoProcessor(processor)
}
}

private fun processExtras(extras: Bundle) {
roomId = extras.getString(KEY_ROOM_ID, "")
roomToken = extras.getString(KEY_ROOM_TOKEN, "")
Expand Down Expand Up @@ -1116,6 +1133,7 @@ class CallActivity : CallBaseActivity() {
videoSource = peerConnectionFactory!!.createVideoSource(false)

videoCapturer!!.initialize(surfaceTextureHelper, applicationContext, videoSource!!.capturerObserver)
initBackgroundBlurViewModel(surfaceTextureHelper)
}
localVideoTrack = peerConnectionFactory!!.createVideoTrack("NCv0", videoSource)
localStream!!.addTrack(localVideoTrack)
Expand Down Expand Up @@ -1250,6 +1268,7 @@ class CallActivity : CallBaseActivity() {
binding!!.cameraButton.setImageResource(R.drawable.ic_videocam_white_24px)
} else {
binding!!.cameraButton.setImageResource(R.drawable.ic_videocam_off_white_24px)
blurBackgroundViewModel.turnOffBlur()
}
toggleMedia(videoOn, true)
} else if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
Expand Down Expand Up @@ -1326,6 +1345,10 @@ class CallActivity : CallBaseActivity() {
raiseHandViewModel!!.clickHandButton()
}

fun toggleBackgroundBlur() {
blurBackgroundViewModel.toggleBackgroundBlur()
}

public override fun onDestroy() {
if (signalingMessageReceiver != null) {
signalingMessageReceiver!!.removeListener(localParticipantMessageListener)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2025 Julius Linus <juliuslinus1@gmail.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package com.nextcloud.talk.camera

import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import io.github.crow_misia.libyuv.AbgrBuffer
import io.github.crow_misia.libyuv.I420Buffer
import io.github.crow_misia.libyuv.PlanePrimitive
import org.webrtc.JavaI420Buffer
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoFrame
import org.webrtc.VideoProcessor
import org.webrtc.VideoSink
import org.webrtc.YuvHelper
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentHashMap

class BackgroundBlurFrameProcessor(val context: Context, val surfaceTextureHelper: SurfaceTextureHelper) :
VideoProcessor,
ImageSegmenterHelper.SegmenterListener {

companion object {
val TAG: String = this::class.java.simpleName
const val GPU_THREAD: String = "BackgroundBlur"
}

private var sink: VideoSink? = null
private var segmenterHelper: ImageSegmenterHelper? = null
private var backgroundBlurGPUProcessor: BackgroundBlurGPUProcessor? = null

// This is to hold meta information between MediaPipe and GPU Render threads, in a thread safe way
private val rotationMap = ConcurrentHashMap<Long, Float>()
private val frameBufferMap = ConcurrentHashMap<Long, ByteBuffer>()

// Dedicated Thread for OpenGL Operations
private var glThread: HandlerThread? = null
private var glHandler: Handler? = null

// SegmentationListener Interface

override fun onError(error: String, errorCode: Int) {
Log.e(TAG, "Error $errorCode: $error")
}

override fun onResults(resultBundle: ImageSegmenterHelper.ResultBundle) {
val rotation = rotationMap[resultBundle.inferenceTime] ?: 0f
val frameBuffer = frameBufferMap[resultBundle.inferenceTime]

// Remove once used to prevent mem leaks
rotationMap.remove(resultBundle.inferenceTime)
frameBufferMap.remove(resultBundle.inferenceTime)

if (frameBuffer == null) {
Log.e(TAG, "Critical Error in onResults: FrameBufferMap[${resultBundle.inferenceTime}] was null")
return
}

glHandler?.post {
// This block runs safely on gpu thread
backgroundBlurGPUProcessor?.let { scaler ->
try {
val drawArray = scaler.process(
resultBundle.mask,
frameBuffer,
resultBundle.width,
resultBundle.height,
rotation
)

val webRTCBuffer = drawArray.convertToWebRTCBuffer(resultBundle.width, resultBundle.height)
val videoFrame = VideoFrame(webRTCBuffer, 0, resultBundle.inferenceTime)

// This should run on the CaptureThread
surfaceTextureHelper.handler.post {
Log.d(TAG, "Sent VideoFrame to sink on :${Thread.currentThread().name}")
sink?.onFrame(videoFrame)

// webRTCBuffer usually needs release() if it's not a JavaI420Buffer wrapper that auto-GCs,
// but JavaI420Buffer.wrap() relies on GC.
videoFrame.release()
}


} catch (e: Exception) {
Log.e(TAG, "Error processing frame on GL Thread", e)
}
}
}
}

// Video Processor Interface

override fun onCapturerStarted(success: Boolean) {
segmenterHelper = ImageSegmenterHelper(context = context, imageSegmenterListener = this)

glThread = HandlerThread(GPU_THREAD).apply { start() }
glHandler = Handler(glThread!!.looper)
glHandler?.post {
backgroundBlurGPUProcessor = BackgroundBlurGPUProcessor(context)
backgroundBlurGPUProcessor?.init()
}
}

override fun onCapturerStopped() {
segmenterHelper?.destroyImageSegmenter()
glHandler?.post {
backgroundBlurGPUProcessor?.release()
backgroundBlurGPUProcessor = null

// Quit thread after cleanup
glThread?.quitSafely()
glThread = null
glHandler = null
}
}

override fun onFrameCaptured(videoFrame: VideoFrame) {
val i420WebRTCBuffer = videoFrame.buffer.toI420()
val width = videoFrame.buffer.width
val height = videoFrame.buffer.height
val rotation = 180.0f - videoFrame.rotation
val videoFrameBuffer = i420WebRTCBuffer?.convertToABGR()

i420WebRTCBuffer?.release()

videoFrameBuffer?.let {
rotationMap[videoFrame.timestampNs] = rotation
frameBufferMap[videoFrame.timestampNs] = it
segmenterHelper?.segmentFrame(it, width, height, videoFrame.timestampNs)
} ?: {
Log.e(TAG, "onFrameCaptured:: Video Frame was null!")
sink?.onFrame(videoFrame)
}
}

override fun setSink(sink: VideoSink?) {
this.sink = sink
}

fun VideoFrame.I420Buffer.convertToABGR() : ByteBuffer {
val dataYSize = dataY.limit() - dataY.position()
val dataUSize = dataU.limit() - dataU.position()
val dataVSize = dataV.limit() - dataV.position()

val planeY = PlanePrimitive.create(strideY, dataY, dataYSize)
val planeU = PlanePrimitive.create(strideU, dataU, dataUSize)
val planeV = PlanePrimitive.create(strideV, dataV, dataVSize)

val libYuvI420Buffer = I420Buffer.wrap(planeY, planeU, planeV, width, height)
val libYuvABGRBuffer = AbgrBuffer.allocate(width, height)
libYuvI420Buffer.convertTo(libYuvABGRBuffer)

return libYuvABGRBuffer.asBuffer()
}

fun ByteArray.convertToWebRTCBuffer(width: Int, height: Int): JavaI420Buffer {
val src = ByteBuffer.allocateDirect(this.size)
src.put(this)

val srcStride = width * 4
val yPlaneSize = width * height
val uvPlaneSize = (width / 2) * (height / 2)

val dstYStride = width
val dstUStride = width / 2
val dstVStride = width / 2

val dstYBuffer = ByteBuffer.allocateDirect(yPlaneSize)
val dstUBuffer = ByteBuffer.allocateDirect(uvPlaneSize)
val dstVBuffer = ByteBuffer.allocateDirect(uvPlaneSize)

YuvHelper.ABGRToI420(
src,
srcStride,
dstYBuffer,
dstYStride,
dstUBuffer,
dstUStride,
dstVBuffer,
dstVStride,
width,
height
)

return JavaI420Buffer.wrap(
width,
height,
dstYBuffer,
dstYStride,
dstUBuffer,
dstUStride,
dstVBuffer,
dstVStride,
null
)
}
}
Loading
Loading