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

https://qiita.com/official-events/93564ad363199fa7999c

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

# つくったもの
SkyWay SDKではカメラソース以外に[任意の映像フレームを入力ソース](https://skyway.ntt.com/ja/docs/user-guide/android-sdk/media-source-and-stream/)として扱うことができるということで、今回はこの機能を利用して、スマホのフロントカメラとバックカメラの映像フレームをリアルタイムに合成してライブ配信するアプリを作ってみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/6c44c552-1257-c9e1-b05f-77438210b228.png)

## 完成イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/ce983c05-d485-ccad-954c-5eb2ecbe7cfe.png)

横レイアウトの場合：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/54717b83-1aa5-c935-b3b6-e8397bad3e98.png)

## 利用シーン
撮影対象を動画で撮りながら、撮影者の映像もいっしょに配信することができるのでライブ感ある実況が可能です。たとえばこんなことに使えます。
- ドライブの実況
- 商品紹介
- 旅行風景の実況
- 面談風景の実況
- 講義の実況
- などなど

アイデア次第で色々なシーンに利用できそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/566326d9-519b-8a56-8e22-8dcce6c5c94e.png)

## 開発環境
- SkyWay Android SDK [v1.3.1](https://github.com/skyway/android-sdk/releases/tag/v1.3.1)
- macOS Ventura 13.4.1
- Android Studio Flamingo | 2022.2.1 Patch 2
- Pixel 7, Android 13

## 作成したコード一式
https://github.com/yuppejp/SkyWayMultiCamera/tree/main

# 実装のポイント

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

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

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

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

```kotlin
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度  |

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

```kotlin:端末の向きとカメラセンサーの向きに封じた回転角度の補正(抜粋)
    // カメラセンサーの向き
    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化します。

```kotlin: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を使っています。

```kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/deaeb40b-9965-afbf-ada4-16908e8ae360.png)

## 完成版
![demo1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/73f0a507-9b47-e001-0c41-d66187a50ae0.gif)

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

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

参考になれば幸いです。

https://github.com/yuppejp/SkyWayMultiCamera/tree/main

## 参考URL
- [音声・映像入力ソースと LocalStream の作成方法 - SkyWay](https://skyway.ntt.com/ja/docs/user-guide/android-sdk/media-source-and-stream/)
- [SkyWay Andoird SDK クイックスタート - SkyWay](https://skyway.ntt.com/ja/docs/user-guide/android-sdk/quickstart/)
- [SkyWay APIリファレンス - SkyWay](https://skyway.ntt.com/ja/docs/api-reference/)
- [Camera 2 API - Android Developpers](https://developer.android.com/reference/android/hardware/camera2/package-summary)