davidliu
Committed by GitHub

Default to scaling and cropping camera output to fit desired dimensions (#558)

  1 +---
  2 +"client-sdk-android": minor
  3 +---
  4 +
  5 +Default to scaling and cropping camera output to fit desired dimensions
  6 +
  7 +* This behavior may be turned off through the `VideoCaptureParams.adaptOutputToDimensions`
@@ -32,6 +32,7 @@ import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera @@ -32,6 +32,7 @@ import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera
32 import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition 32 import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition
33 import io.livekit.android.room.track.video.CameraCapturerWithSize 33 import io.livekit.android.room.track.video.CameraCapturerWithSize
34 import io.livekit.android.room.track.video.CaptureDispatchObserver 34 import io.livekit.android.room.track.video.CaptureDispatchObserver
  35 +import io.livekit.android.room.track.video.ScaleCropVideoProcessor
35 import io.livekit.android.room.track.video.VideoCapturerWithSize 36 import io.livekit.android.room.track.video.VideoCapturerWithSize
36 import io.livekit.android.room.util.EncodingUtils 37 import io.livekit.android.room.util.EncodingUtils
37 import io.livekit.android.util.FlowObservable 38 import io.livekit.android.util.FlowObservable
@@ -473,11 +474,22 @@ constructor( @@ -473,11 +474,22 @@ constructor(
473 videoProcessor: VideoProcessor? = null, 474 videoProcessor: VideoProcessor? = null,
474 ): LocalVideoTrack { 475 ): LocalVideoTrack {
475 val source = peerConnectionFactory.createVideoSource(options.isScreencast) 476 val source = peerConnectionFactory.createVideoSource(options.isScreencast)
476 - source.setVideoProcessor(videoProcessor) 477 +
  478 + val finalVideoProcessor = if (options.captureParams.adaptOutputToDimensions) {
  479 + ScaleCropVideoProcessor(
  480 + targetWidth = options.captureParams.width,
  481 + targetHeight = options.captureParams.height,
  482 + ).apply {
  483 + childVideoProcessor = videoProcessor
  484 + }
  485 + } else {
  486 + videoProcessor
  487 + }
  488 + source.setVideoProcessor(finalVideoProcessor)
477 489
478 val surfaceTextureHelper = SurfaceTextureHelper.create("VideoCaptureThread", rootEglBase.eglBaseContext) 490 val surfaceTextureHelper = SurfaceTextureHelper.create("VideoCaptureThread", rootEglBase.eglBaseContext)
479 491
480 - // Dispatch raw frames to local renderer only if not using a VideoProcessor. 492 + // Dispatch raw frames to local renderer only if not using a user-provided VideoProcessor.
481 val dispatchObserver = if (videoProcessor == null) { 493 val dispatchObserver = if (videoProcessor == null) {
482 CaptureDispatchObserver().apply { 494 CaptureDispatchObserver().apply {
483 registerObserver(source.capturerObserver) 495 registerObserver(source.capturerObserver)
@@ -29,10 +29,27 @@ data class LocalVideoTrackOptions( @@ -29,10 +29,27 @@ data class LocalVideoTrackOptions(
29 val captureParams: VideoCaptureParameter = VideoPreset169.H720.capture, 29 val captureParams: VideoCaptureParameter = VideoPreset169.H720.capture,
30 ) 30 )
31 31
32 -data class VideoCaptureParameter( 32 +data class VideoCaptureParameter
  33 +@JvmOverloads
  34 +constructor(
  35 + /**
  36 + * Desired width.
  37 + */
33 val width: Int, 38 val width: Int,
  39 + /**
  40 + * Desired height.
  41 + */
34 val height: Int, 42 val height: Int,
  43 + /**
  44 + * Capture frame rate.
  45 + */
35 val maxFps: Int, 46 val maxFps: Int,
  47 + /**
  48 + * Sometimes the capturer may not support the exact desired dimensions requested.
  49 + * If this is enabled, it will scale down and crop the captured frames to the
  50 + * same aspect ratio as [width]:[height].
  51 + */
  52 + val adaptOutputToDimensions: Boolean = true,
36 ) 53 )
37 54
38 data class VideoEncoding( 55 data class VideoEncoding(
@@ -213,7 +230,7 @@ enum class ScreenSharePresets( @@ -213,7 +230,7 @@ enum class ScreenSharePresets(
213 * Uses the original resolution without resizing. 230 * Uses the original resolution without resizing.
214 */ 231 */
215 ORIGINAL( 232 ORIGINAL(
216 - VideoCaptureParameter(0, 0, 30), 233 + VideoCaptureParameter(0, 0, 30, adaptOutputToDimensions = false),
217 VideoEncoding(7_000_000, 30), 234 VideoEncoding(7_000_000, 30),
218 ) 235 )
219 } 236 }
  1 +/*
  2 + * Copyright 2024 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.track.video
  18 +
  19 +import androidx.annotation.CallSuper
  20 +import livekit.org.webrtc.VideoFrame
  21 +import livekit.org.webrtc.VideoProcessor
  22 +import livekit.org.webrtc.VideoSink
  23 +
  24 +/**
  25 + * A VideoProcessor that can be chained together.
  26 + *
  27 + * Child classes should propagate frames down to the
  28 + * next link through [continueChain].
  29 + */
  30 +abstract class ChainVideoProcessor : VideoProcessor {
  31 +
  32 + /**
  33 + * The video sink where frames that have been completely processed are sent.
  34 + */
  35 + var videoSink: VideoSink? = null
  36 + private set
  37 +
  38 + /**
  39 + * The next link in the chain to feed frames to.
  40 + *
  41 + * Setting [childVideoProcessor] to null will mean that this is object
  42 + * the end of the chain, and processed frames are ready to be published.
  43 + */
  44 + var childVideoProcessor: VideoProcessor? = null
  45 + set(value) {
  46 + value?.setSink(videoSink)
  47 + field = value
  48 + }
  49 +
  50 + @CallSuper
  51 + override fun onCapturerStarted(started: Boolean) {
  52 + childVideoProcessor?.onCapturerStarted(started)
  53 + }
  54 +
  55 + @CallSuper
  56 + override fun onCapturerStopped() {
  57 + childVideoProcessor?.onCapturerStopped()
  58 + }
  59 +
  60 + final override fun setSink(videoSink: VideoSink?) {
  61 + childVideoProcessor?.setSink(videoSink)
  62 + this.videoSink = videoSink
  63 + }
  64 +
  65 + /**
  66 + * A utility method to pass the frame down to the next link in the chain.
  67 + */
  68 + protected fun continueChain(frame: VideoFrame) {
  69 + childVideoProcessor?.onFrameCaptured(frame) ?: videoSink?.onFrame(frame)
  70 + }
  71 +}
  1 +/*
  2 + * Copyright 2024 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.track.video
  18 +
  19 +import livekit.org.webrtc.VideoFrame
  20 +import kotlin.math.max
  21 +import kotlin.math.roundToInt
  22 +
  23 +/**
  24 + * A video processor that scales down and crops to match
  25 + * the target dimensions and aspect ratio.
  26 + *
  27 + * If the frames are smaller than the target dimensions,
  28 + * upscaling will not occur, instead only cropping to match
  29 + * the aspect ratio.
  30 + */
  31 +class ScaleCropVideoProcessor(
  32 + var targetWidth: Int,
  33 + var targetHeight: Int,
  34 +) : ChainVideoProcessor() {
  35 +
  36 + override fun onFrameCaptured(frame: VideoFrame) {
  37 + if (frame.rotatedWidth == targetWidth && frame.rotatedHeight == targetHeight) {
  38 + // already the perfect size, just pass along the frame.
  39 + continueChain(frame)
  40 + return
  41 + }
  42 +
  43 + val width = frame.buffer.width
  44 + val height = frame.buffer.height
  45 + // Ensure target dimensions don't exceed source dimensions
  46 + val scaleWidth: Int
  47 + val scaleHeight: Int
  48 +
  49 + if (targetWidth > width || targetHeight > height) {
  50 + // Calculate scale factor to fit within source dimensions
  51 + val widthScale = targetWidth.toDouble() / width
  52 + val heightScale = targetHeight.toDouble() / height
  53 + val scale = max(widthScale, heightScale)
  54 +
  55 + // Apply scale to target dimensions
  56 + scaleWidth = (targetWidth / scale).roundToInt()
  57 + scaleHeight = (targetHeight / scale).roundToInt()
  58 + } else {
  59 + scaleWidth = targetWidth
  60 + scaleHeight = targetHeight
  61 + }
  62 +
  63 + val sourceRatio = width.toDouble() / height
  64 + val targetRatio = scaleWidth.toDouble() / scaleHeight
  65 +
  66 + val cropWidth: Int
  67 + val cropHeight: Int
  68 +
  69 + // Calculate crop dimension
  70 + if (sourceRatio > targetRatio) {
  71 + // source is wider, crop height
  72 + cropHeight = height
  73 + cropWidth = (height * targetRatio).roundToInt()
  74 + } else {
  75 + // source is taller, crop width
  76 + cropWidth = width
  77 + cropHeight = (width / targetRatio).roundToInt()
  78 + }
  79 +
  80 + // Calculate center offsets
  81 + val offsetX = (width - cropWidth) / 2
  82 + val offsetY = (height - cropHeight) / 2
  83 + val newBuffer = frame.buffer.cropAndScale(
  84 + offsetX,
  85 + offsetY,
  86 + cropWidth,
  87 + cropHeight,
  88 + scaleWidth,
  89 + scaleHeight,
  90 + )
  91 +
  92 + val croppedFrame = VideoFrame(newBuffer, frame.rotation, frame.timestampNs)
  93 + continueChain(croppedFrame)
  94 + croppedFrame.release()
  95 + }
  96 +}