4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

新しくなったSkyWayを使ってみよう!

【SkyWay】マルチカメラによるライブ配信アプリの制作

Posted at

新生SkyWayを使って、スマホのフロントカメラとバックカメラの2つを同時に使用したマルチカメラ方式のライブ配信アプリを作ってみました。

つくったもの

SkyWay SDKではカメラソース以外に任意の映像フレームを入力ソースとして扱うことができるということで、今回はこの機能を利用して、スマホのフロントカメラとバックカメラの映像フレームをリアルタイムに合成してライブ配信するアプリを作ってみました。
image.png

完成イメージ

image.png

横レイアウトの場合:
image.png

利用シーン

撮影対象を動画で撮りながら、撮影者の映像もいっしょに配信することができるのでライブ感ある実況が可能です。たとえばこんなことに使えます。

  • ドライブの実況
  • 商品紹介
  • 旅行風景の実況
  • 面談風景の実況
  • 講義の実況
  • などなど

アイデア次第で色々なシーンに利用できそうです。
image.png

開発環境

  • SkyWay Android SDK v1.3.1
  • macOS Ventura 13.4.1
  • Android Studio Flamingo | 2022.2.1 Patch 2
  • Pixel 7, Android 13

作成したコード一式

実装のポイント

フロントカメラとバックカメラの映像をリアルタイムに合成

Androidでフロントカメラとバックカメラの同時アクセスを可能とするCamera2 APIを使用しました。それぞれのカメラデバイスにImageReaderを設定し、映像フレームをsetRepeatingRequestで要求します。

CustomVideoFrameSourceの生成

二つのカメラに同時アクセスし、さらにリアルタイムでフレームをレンダリングするとCPUにかなり負荷をかけてしまうため、画像サイズを小さめに設定しています。(1/2サイズに)

val sourceSize = Size(1920 / 2, 1080 / 2)
val source = CustomVideoFrameSource(sourceSize.width, sourceSize.height)
val localVideoStream = source.createStream()

映像フレームの合成

Bitmapに関連づけたCanvasに重ね合わせて描画することで映像フレームを合成します。最初にバックカメラの映像をフルサイズで描画し、その上に小さくスケーリングしたフロントカメラの映像を上書きします。これをフレーム毎に連続して実行することで合成したビデオ映像になります。

private fun combineImages(mainBitmap: Bitmap, subBitmap: Bitmap, mainRotation: Int = 0, subRotation: Int = 0): Bitmap {
    // メイン画像をキャンバスに描画
    val resultBitmap = Bitmap.createBitmap(mainBitmap.width, mainBitmap.height, mainBitmap.config)
    val canvas = Canvas(resultBitmap)
    val matrix = Matrix()
    matrix.postRotate(mainRotation.toFloat(), mainBitmap.width / 2f, mainBitmap.height / 2f)
    canvas.drawBitmap(mainBitmap, matrix, null)

    // サブ画像を小さくスケーリング
    val scale = 1f / 3
    val scaledSubBitmap = Bitmap.createScaledBitmap(
        subBitmap,
        (mainBitmap.width * scale).toInt(),
        (mainBitmap.height * scale).toInt(),
        false
    )

    // サブ画像の位置調整と回転
    val padding = 16f
    val left = mainBitmap.width - scaledSubBitmap.width - padding
    val top = mainBitmap.height - scaledSubBitmap.height - padding
    val matrix2 = Matrix().apply {
        postRotate(subRotation.toFloat(), scaledSubBitmap.width / 2f, scaledSubBitmap.height / 2f)
        postTranslate(left - scaledSubBitmap.width / 2f, top - scaledSubBitmap.height / 2f)
    }
    val rotatedSubBitmap = Bitmap.createBitmap(
        scaledSubBitmap, 0, 0,
        scaledSubBitmap.width, scaledSubBitmap.height, matrix2, true
    )

    // サブ画像をキャンバスに描画
    canvas.drawBitmap(rotatedSubBitmap, left, top, null)

    return resultBitmap
}

カメラセンサーと端末の向きに応じて画像の向きを補正

少々やっかいなのが、カメラからの映像フレームの向きが期待通りの向きになってくれません。Pixel 7の場合のセンサー角度は、フロントカメラが270度、バックカメラが90度回転しています。

Google Pixel 7 センサー角度
フロントカメラ 270度
バックカメラ 90度

さらに端末の向きがポートレートなのか、ランドスケープ(これも方向が2種類)なのかを考えながら2つのカメラ映像を合成する必要があります。

端末の向きとカメラセンサーの向きに封じた回転角度の補正(抜粋)
    // カメラセンサーの向き
    val frontCameraRatation = getCameraRotation(manager, frontCameraId)
    val backCameraRatation = getCameraRotation(manager, backCameraId)

    // 端末の向き
    val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
    val rotation = windowManager.defaultDisplay.rotation

    // 回転角度の補正
    var frontRation = 0
    var backRation = 0
    when (rotation) {
        Surface.ROTATION_0 -> {
            frontRation = frontCameraRatation
            backRation = backCameraRatation
        }

        Surface.ROTATION_90 -> {
            frontRation = frontCameraRatation - 90
            backRation = backCameraRatation + 90
        }

        Surface.ROTATION_270 -> {
            frontRation = 0
            backRation = 0
        }

        else -> {}
    }

    // イメージ合成
    if (frameData.isAvailable()) {
        val combinedBitmap =
            combineImages(frameData.backBitmap!!, frameData.frontBitmap!!, frontRation, backRation)
        source.updateFrame(combinedBitmap, 0)
    }

JetpackComposeでビューを実装

ルームメンバーの参加や退席が非同期に発生するためこのあたりのUIをリアクティブに表現するために、ビューはJetpackComposeで実装しました。

SkyWayの映像表示コンポーネントであるLocalVideoStreamとRemoteVideoStreamをAndroidViewでラップし、Composable化します。

SkyWayVideoView.kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import com.ntt.skyway.core.content.local.LocalVideoStream
import com.ntt.skyway.core.content.remote.RemoteVideoStream
import com.ntt.skyway.core.content.sink.SurfaceViewRenderer

@Composable
fun SkyWayLocalVideoView(localVideoStream: LocalVideoStream?, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context ->
            val remoteVideoRenderer = SurfaceViewRenderer(context)
            remoteVideoRenderer.setup()
            remoteVideoRenderer
        },
        update = { surfaceViewRenderer ->
            localVideoStream?.removeAllRenderer()
            localVideoStream?.addRenderer(surfaceViewRenderer)
        },
        modifier = modifier
    )
}

@Composable
fun SkyWayRemoteVideoView(remoteVideoStream: RemoteVideoStream?, modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context ->
            val remoteVideoRenderer = SurfaceViewRenderer(context)
            remoteVideoRenderer.setup()
            remoteVideoRenderer
        },
        update = { surfaceViewRenderer ->
            remoteVideoStream?.removeAllRenderer()
            remoteVideoStream?.addRenderer(surfaceViewRenderer)
        },
        modifier = modifier
    )
}

メンバーの参加人数に応じてグリッドを変化させる

RemoteVideoStreamをMutableLiveDataで監視し、メンバー数に変化があればグリッドを再構成します。可変のグリッドに対応するためにLazyVerticalGridを使っています。

RemoteMembersView.kt
@Composable
fun RemoteMembersView(
    remoteVideoStreamList: MutableLiveData<MutableList<RemoteVideoStream>>?,
    modifier: Modifier = Modifier
) {
    val isInEditMode = LocalInspectionMode.current

    Box(modifier = modifier) {
        BoxWithConstraints {
            // メンバー数
            var memberCount = 0
            if (isInEditMode) {
                memberCount = 9 // レイアウト確認用
            } else {
                if (remoteVideoStreamList?.value != null) {
                    memberCount = remoteVideoStreamList.value!!.size
                }
            }

            // グリッドの桁数と行数
            var col = 1
            var row = 1
            if (memberCount > 0) {
                col = ceil(sqrt(memberCount.toDouble())).toInt() // 少数部切り上げ
                row = ceil(memberCount.toDouble() / col.toDouble()).toInt() // 少数部切り上げ
            }
            val gridWidth = maxWidth / col
            val gridHeight = maxHeight / row
            val padding = 2.dp

            LazyVerticalGrid(
                columns = GridCells.Fixed(col),
                modifier = Modifier.fillMaxSize(),
                verticalArrangement = Arrangement.Center
            ) {
                items(memberCount) { index ->
                    Box(
                        contentAlignment = Alignment.Center,
                        modifier = Modifier
                            .width(gridWidth - padding)
                            .height(gridHeight - padding)
                            .padding(padding)
                            .background(Color.LightGray)
                    ) {
                        if (isInEditMode) {
                            // レイアウト確認用
                            RoomMemberItemViewDebug((index+1).toString())
                        } else {
                            val videoStream = remoteVideoStreamList?.value?.get(index)
                            videoStream?.let {
                                SkyWayRemoteVideoView(videoStream)
                            }
                        }
                    }
                }
            }
        }
    }
}

参加人数の違いによるグリッドの表示例です。人数に応じて画面に収まるようにグリッド数が変化します。
image.png

完成版

demo1.gif

まとめ

SkyWayのドキュメントが充実していたので、チュートリアルとサンプルを見ながら進めていくことで問題なく実装することができました。

ビデオ通話系アプリのデバッグではテスト用の相手端末を複数用意するのがしんどいところですが、JavaScript版のチュートリアルアプリを利用することで端末数をかせぐことができました。ブラウザのタブの数だけRoomにJoinできるので便利でした。このように、すぐに動作するサンプルが揃っていたのでストレスなく開発が捗りました。

参考になれば幸いです。

参考URL

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?