@apirtc/expo-apirtc-options-plugin 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ package com.reactnativeapirtc
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Paint
6
+ import android.graphics.PorterDuff
7
+ import android.graphics.PorterDuffXfermode
8
+ import android.util.Log
9
+ import com.google.android.gms.tasks.Tasks
10
+ import com.google.mlkit.vision.common.InputImage
11
+ import com.google.mlkit.vision.segmentation.Segmentation
12
+ import com.google.mlkit.vision.segmentation.Segmenter
13
+ import com.google.mlkit.vision.segmentation.selfie.SelfieSegmenterOptions
14
+ import org.webrtc.JavaI420Buffer
15
+ import org.webrtc.VideoFrame
16
+ import org.webrtc.VideoProcessor
17
+ import org.webrtc.VideoSink
18
+ import java.nio.ByteBuffer
19
+ import java.nio.ByteOrder
20
+ import java.util.concurrent.TimeUnit
21
+ import java.util.concurrent.atomic.AtomicBoolean
22
+
23
+ /**
24
+ * BackgroundImageProcessor implements WebRTC's VideoProcessor interface.
25
+ *
26
+ * Replaces the background with a provided Bitmap image, keeping the person
27
+ * (detected via ML Kit Selfie Segmentation) sharp and in focus.
28
+ *
29
+ * Flow: Camera → onFrameCaptured → [segment + composite] → downstreamSink
30
+ */
31
+ class BackgroundImageProcessor(private val backgroundBitmap: Bitmap) : VideoProcessor {
32
+
33
+ companion object {
34
+ private const val TAG = "BgImageProcessor"
35
+ private const val SEGMENTATION_INTERVAL = 2
36
+ private const val SEG_WIDTH = 256
37
+ private const val SEG_HEIGHT = 192
38
+ private const val SEGMENTATION_TIMEOUT_MS = 200L
39
+ private const val SEGMENTATION_TIMEOUT_FIRST_MS = 600L
40
+ private const val MASK_DECAY = 0.5f
41
+ private const val MASK_THRESHOLD_LOW = 0.25f
42
+ private const val MASK_THRESHOLD_HIGH = 0.50f
43
+ private const val VERTICAL_FILL_EXTEND = 8
44
+ private const val VERTICAL_FILL_ANCHOR = 0.55f
45
+ }
46
+
47
+ @Volatile
48
+ private var downstreamSink: VideoSink? = null
49
+
50
+ private val enabled = AtomicBoolean(false)
51
+ private var segmenter: Segmenter? = null
52
+ private var frameCount = 0L
53
+
54
+ @Volatile private var cachedMaskBuffer: ByteBuffer? = null
55
+ @Volatile private var cachedMaskWidth = 0
56
+ @Volatile private var cachedMaskHeight = 0
57
+ @Volatile private var previousMaskFloats: FloatArray? = null
58
+
59
+ private var compositeBitmap: Bitmap? = null
60
+ private var scaledBackground: Bitmap? = null
61
+ private var cachedBgWidth = -1
62
+ private var cachedBgHeight = -1
63
+ private var cachedBgRotation = -1
64
+
65
+ // =====================================================================
66
+ // VideoProcessor interface
67
+ // =====================================================================
68
+
69
+ override fun setSink(sink: VideoSink?) {
70
+ downstreamSink = sink
71
+ }
72
+
73
+ override fun onCapturerStarted(success: Boolean) {}
74
+
75
+ override fun onCapturerStopped() {}
76
+
77
+ override fun onFrameCaptured(frame: VideoFrame) {
78
+ val sink = downstreamSink ?: return
79
+
80
+ if (!enabled.get()) {
81
+ sink.onFrame(frame)
82
+ return
83
+ }
84
+
85
+ try {
86
+ val processedFrame = processFrame(frame)
87
+ if (processedFrame != null) {
88
+ sink.onFrame(processedFrame)
89
+ processedFrame.release()
90
+ } else {
91
+ sink.onFrame(frame)
92
+ }
93
+ } catch (e: Exception) {
94
+ Log.e(TAG, "Error processing frame, forwarding original", e)
95
+ sink.onFrame(frame)
96
+ }
97
+ }
98
+
99
+ // =====================================================================
100
+ // Enable / Disable
101
+ // =====================================================================
102
+
103
+ fun enable() {
104
+ if (enabled.getAndSet(true)) return
105
+ Log.d(TAG, "Enabling background image processor")
106
+
107
+ val options = SelfieSegmenterOptions.Builder()
108
+ .setDetectorMode(SelfieSegmenterOptions.STREAM_MODE)
109
+ .enableRawSizeMask()
110
+ .build()
111
+ segmenter = Segmentation.getClient(options)
112
+
113
+ frameCount = 0
114
+ cachedMaskBuffer = null
115
+ Log.d(TAG, "Background image processor enabled")
116
+ }
117
+
118
+ fun disable() {
119
+ if (!enabled.getAndSet(false)) return
120
+ Log.d(TAG, "Disabling background image processor")
121
+
122
+ segmenter?.close()
123
+ segmenter = null
124
+ compositeBitmap?.recycle()
125
+ compositeBitmap = null
126
+ scaledBackground?.recycle()
127
+ scaledBackground = null
128
+ cachedMaskBuffer = null
129
+ previousMaskFloats = null
130
+ cachedBgWidth = -1
131
+ cachedBgHeight = -1
132
+ cachedBgRotation = -1
133
+
134
+ Log.d(TAG, "Background image processor disabled")
135
+ }
136
+
137
+ // =====================================================================
138
+ // Frame Processing Pipeline
139
+ // =====================================================================
140
+
141
+ private fun processFrame(frame: VideoFrame): VideoFrame? {
142
+ val buffer = frame.buffer
143
+ buffer.retain()
144
+
145
+ try {
146
+ val width = buffer.width
147
+ val height = buffer.height
148
+
149
+ val bitmap = i420ToBitmap(buffer, width, height) ?: return null
150
+
151
+ frameCount++
152
+ if (frameCount % SEGMENTATION_INTERVAL == 0L || cachedMaskBuffer == null) {
153
+ updateSegmentationMask(bitmap)
154
+ }
155
+
156
+ val mask = cachedMaskBuffer ?: run {
157
+ bitmap.recycle()
158
+ return null
159
+ }
160
+
161
+ // The raw frame buffer is always in the camera sensor orientation (landscape
162
+ // for most phones), and frame.rotation tells the renderer how many degrees to
163
+ // rotate the frame for display. We must pre-rotate the background by the
164
+ // OPPOSITE angle so that after the renderer applies its rotation the image
165
+ // appears upright and fills the display area.
166
+ val rotation = frame.rotation
167
+ if (scaledBackground == null ||
168
+ cachedBgWidth != width ||
169
+ cachedBgHeight != height ||
170
+ cachedBgRotation != rotation) {
171
+ scaledBackground?.recycle()
172
+ // Effective display dimensions after renderer rotation
173
+ val dispW = if (rotation == 90 || rotation == 270) height else width
174
+ val dispH = if (rotation == 90 || rotation == 270) width else height
175
+ // Center-crop to display aspect ratio, then pre-rotate to match raw frame
176
+ val cropped = centerCropBitmap(backgroundBitmap, dispW, dispH)
177
+ scaledBackground = if (rotation != 0) rotateBitmap(cropped, -rotation.toFloat()) else cropped
178
+ cachedBgWidth = width
179
+ cachedBgHeight = height
180
+ cachedBgRotation = rotation
181
+ }
182
+
183
+ applyImageComposite(bitmap, width, height, mask, cachedMaskWidth, cachedMaskHeight)
184
+ bitmap.recycle()
185
+
186
+ val comp = compositeBitmap ?: return null
187
+ val i420Buffer = bitmapToI420Buffer(comp, width, height)
188
+ return VideoFrame(i420Buffer, frame.rotation, frame.timestampNs)
189
+
190
+ } catch (e: Exception) {
191
+ Log.e(TAG, "processFrame error", e)
192
+ return null
193
+ } finally {
194
+ buffer.release()
195
+ }
196
+ }
197
+
198
+ private fun updateSegmentationMask(bitmap: Bitmap) {
199
+ val seg = segmenter ?: return
200
+ val isFirstMask = cachedMaskBuffer == null
201
+ val timeout = if (isFirstMask) SEGMENTATION_TIMEOUT_FIRST_MS else SEGMENTATION_TIMEOUT_MS
202
+
203
+ try {
204
+ val scaled = Bitmap.createScaledBitmap(bitmap, SEG_WIDTH, SEG_HEIGHT, true)
205
+ val input = InputImage.fromBitmap(scaled, 0)
206
+
207
+ val result = Tasks.await(seg.process(input), timeout, TimeUnit.MILLISECONDS)
208
+
209
+ val maskBuf = result.buffer
210
+ cachedMaskWidth = result.width
211
+ cachedMaskHeight = result.height
212
+ val size = cachedMaskWidth * cachedMaskHeight
213
+
214
+ maskBuf.rewind()
215
+ val rawFloats = FloatArray(size) { if (maskBuf.remaining() >= 4) maskBuf.float else 0f }
216
+
217
+ // Column fill: connect upper and lower body in each column
218
+ val filled = fillVerticalGaps(rawFloats, cachedMaskWidth, cachedMaskHeight)
219
+
220
+ // Max-blend with decayed previous: fills transient holes, ghost fades in 2 frames
221
+ val prev = previousMaskFloats
222
+ val blended = if (prev != null && prev.size == size) {
223
+ FloatArray(size) { i -> maxOf(filled[i], MASK_DECAY * prev[i]) }
224
+ } else filled
225
+ previousMaskFloats = blended
226
+
227
+ val newMask = ByteBuffer.allocateDirect(size * 4)
228
+ newMask.order(ByteOrder.nativeOrder())
229
+ for (f in blended) newMask.putFloat(f)
230
+ newMask.rewind()
231
+ cachedMaskBuffer = newMask
232
+
233
+ if (scaled !== bitmap) scaled.recycle()
234
+
235
+ } catch (e: java.util.concurrent.TimeoutException) {
236
+ Log.d(TAG, "Segmentation timeout, using cached mask")
237
+ } catch (e: Exception) {
238
+ Log.w(TAG, "Segmentation error (frame #$frameCount)", e)
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Composite: background image → punch out person area → draw sharp person in front.
244
+ */
245
+ private fun fillVerticalGaps(floats: FloatArray, width: Int, height: Int): FloatArray {
246
+ val out = floats.copyOf()
247
+ for (x in 0 until width) {
248
+ var topY = -1
249
+ var bottomY = -1
250
+ for (y in 0 until height) {
251
+ if (floats[y * width + x] > VERTICAL_FILL_ANCHOR) {
252
+ if (topY == -1) topY = y
253
+ bottomY = y
254
+ }
255
+ }
256
+ if (topY != -1) {
257
+ val extendedBottom = minOf(bottomY + VERTICAL_FILL_EXTEND, height - 1)
258
+ for (y in topY..extendedBottom) {
259
+ val idx = y * width + x
260
+ if (out[idx] < 0.6f) out[idx] = 0.6f
261
+ }
262
+ }
263
+ }
264
+ return out
265
+ }
266
+
267
+ private fun applyImageComposite(
268
+ original: Bitmap, width: Int, height: Int,
269
+ maskBuffer: ByteBuffer, maskWidth: Int, maskHeight: Int
270
+ ) {
271
+ val bg = scaledBackground ?: return
272
+
273
+ if (compositeBitmap == null ||
274
+ compositeBitmap!!.width != width ||
275
+ compositeBitmap!!.height != height) {
276
+ compositeBitmap?.recycle()
277
+ compositeBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
278
+ }
279
+
280
+ // Build mask bitmap from segmentation floats
281
+ val maskPixels = IntArray(maskWidth * maskHeight)
282
+ maskBuffer.rewind()
283
+ for (i in maskPixels.indices) {
284
+ val raw = if (maskBuffer.remaining() >= 4) maskBuffer.float else 0f
285
+ val conf = when {
286
+ raw < MASK_THRESHOLD_LOW -> 0f
287
+ raw > MASK_THRESHOLD_HIGH -> 1f
288
+ else -> (raw - MASK_THRESHOLD_LOW) / (MASK_THRESHOLD_HIGH - MASK_THRESHOLD_LOW)
289
+ }
290
+ val alpha = (conf * 255).toInt().coerceIn(0, 255)
291
+ maskPixels[i] = (alpha shl 24) or 0x00FFFFFF
292
+ }
293
+ val maskBmp = Bitmap.createBitmap(maskPixels, maskWidth, maskHeight, Bitmap.Config.ARGB_8888)
294
+ val scaledMask = if (maskWidth != width || maskHeight != height) {
295
+ val s = Bitmap.createScaledBitmap(maskBmp, width, height, true)
296
+ maskBmp.recycle()
297
+ s
298
+ } else maskBmp
299
+
300
+ // Build person layer: original pixels with alpha from mask.
301
+ // At edges, alpha is partial → lerp between BG image and original → no halo.
302
+ val personBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
303
+ val personCanvas = Canvas(personBitmap)
304
+ personCanvas.drawBitmap(original, 0f, 0f, null)
305
+ val dstInPaint = Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN) }
306
+ personCanvas.drawBitmap(scaledMask, 0f, 0f, dstInPaint)
307
+
308
+ val canvas = Canvas(compositeBitmap!!)
309
+ canvas.drawBitmap(bg, 0f, 0f, null)
310
+ canvas.drawBitmap(personBitmap, 0f, 0f, null)
311
+ scaledMask.recycle()
312
+ personBitmap.recycle()
313
+ }
314
+
315
+ // =====================================================================
316
+ // Bitmap Helpers
317
+ // =====================================================================
318
+
319
+ private fun rotateBitmap(src: Bitmap, degrees: Float): Bitmap {
320
+ val matrix = android.graphics.Matrix().apply { postRotate(degrees) }
321
+ val rotated = Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
322
+ src.recycle()
323
+ return rotated
324
+ }
325
+
326
+ private fun centerCropBitmap(src: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap {
327
+ val srcAspect = src.width.toFloat() / src.height
328
+ val dstAspect = targetWidth.toFloat() / targetHeight
329
+
330
+ val srcX: Int
331
+ val srcY: Int
332
+ val srcW: Int
333
+ val srcH: Int
334
+
335
+ if (srcAspect > dstAspect) {
336
+ srcH = src.height
337
+ srcW = (src.height * dstAspect).toInt()
338
+ srcX = (src.width - srcW) / 2
339
+ srcY = 0
340
+ } else {
341
+ srcW = src.width
342
+ srcH = (src.width / dstAspect).toInt()
343
+ srcX = 0
344
+ srcY = (src.height - srcH) / 2
345
+ }
346
+
347
+ val cropped = Bitmap.createBitmap(src, srcX, srcY, srcW, srcH)
348
+ val scaled = Bitmap.createScaledBitmap(cropped, targetWidth, targetHeight, true)
349
+ if (cropped !== src) cropped.recycle()
350
+ return scaled
351
+ }
352
+
353
+ // =====================================================================
354
+ // Color Space Conversions (I420 ↔ Bitmap)
355
+ // =====================================================================
356
+
357
+ private fun i420ToBitmap(buffer: VideoFrame.Buffer, width: Int, height: Int): Bitmap? {
358
+ return try {
359
+ val i420 = buffer.toI420() ?: return null
360
+
361
+ val yPlane = i420.dataY
362
+ val uPlane = i420.dataU
363
+ val vPlane = i420.dataV
364
+ val yStride = i420.strideY
365
+ val uStride = i420.strideU
366
+ val vStride = i420.strideV
367
+
368
+ val nv21 = ByteArray(width * height * 3 / 2)
369
+
370
+ for (row in 0 until height) {
371
+ yPlane.position(row * yStride)
372
+ yPlane.get(nv21, row * width, width)
373
+ }
374
+
375
+ val chromaHeight = height / 2
376
+ val chromaWidth = width / 2
377
+ for (row in 0 until chromaHeight) {
378
+ for (col in 0 until chromaWidth) {
379
+ val nv21Offset = width * height + row * width + col * 2
380
+ vPlane.position(row * vStride + col)
381
+ nv21[nv21Offset] = vPlane.get()
382
+ uPlane.position(row * uStride + col)
383
+ nv21[nv21Offset + 1] = uPlane.get()
384
+ }
385
+ }
386
+ i420.release()
387
+
388
+ val yuvImage = android.graphics.YuvImage(
389
+ nv21, android.graphics.ImageFormat.NV21, width, height, null
390
+ )
391
+ val out = java.io.ByteArrayOutputStream()
392
+ yuvImage.compressToJpeg(android.graphics.Rect(0, 0, width, height), 85, out)
393
+ val jpegBytes = out.toByteArray()
394
+ val decoded = android.graphics.BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size)
395
+ decoded?.copy(Bitmap.Config.ARGB_8888, true)?.also { decoded.recycle() }
396
+ } catch (e: Exception) {
397
+ Log.e(TAG, "I420→Bitmap error", e)
398
+ null
399
+ }
400
+ }
401
+
402
+ private fun bitmapToI420Buffer(bitmap: Bitmap, width: Int, height: Int): JavaI420Buffer {
403
+ val argbBuffer = ByteBuffer.allocateDirect(width * height * 4)
404
+ bitmap.copyPixelsToBuffer(argbBuffer)
405
+ argbBuffer.rewind()
406
+
407
+ val i420Buffer = JavaI420Buffer.allocate(width, height)
408
+ val yBuf = i420Buffer.dataY
409
+ val uBuf = i420Buffer.dataU
410
+ val vBuf = i420Buffer.dataV
411
+
412
+ for (y in 0 until height) {
413
+ for (x in 0 until width) {
414
+ val idx = (y * width + x) * 4
415
+ val r = argbBuffer.get(idx).toInt() and 0xFF
416
+ val g = argbBuffer.get(idx + 1).toInt() and 0xFF
417
+ val b = argbBuffer.get(idx + 2).toInt() and 0xFF
418
+
419
+ val yVal = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
420
+ yBuf.put(y * i420Buffer.strideY + x, yVal.coerceIn(0, 255).toByte())
421
+
422
+ if (y % 2 == 0 && x % 2 == 0) {
423
+ val uVal = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
424
+ val vVal = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
425
+ val chromaIdx = (y / 2) * i420Buffer.strideU + (x / 2)
426
+ uBuf.put(chromaIdx, uVal.coerceIn(0, 255).toByte())
427
+ vBuf.put(chromaIdx, vVal.coerceIn(0, 255).toByte())
428
+ }
429
+ }
430
+ }
431
+ return i420Buffer
432
+ }
433
+ }