新生SkyWayを使って、スマホのフロントカメラとバックカメラの2つを同時に使用したマルチカメラ方式のライブ配信アプリを作ってみました。
つくったもの
SkyWay SDKではカメラソース以外に任意の映像フレームを入力ソースとして扱うことができるということで、今回はこの機能を利用して、スマホのフロントカメラとバックカメラの映像フレームをリアルタイムに合成してライブ配信するアプリを作ってみました。
完成イメージ
利用シーン
撮影対象を動画で撮りながら、撮影者の映像もいっしょに配信することができるのでライブ感ある実況が可能です。たとえばこんなことに使えます。
- ドライブの実況
- 商品紹介
- 旅行風景の実況
- 面談風景の実況
- 講義の実況
- などなど
開発環境
- 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化します。
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を使っています。
@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)
}
}
}
}
}
}
}
}
参加人数の違いによるグリッドの表示例です。人数に応じて画面に収まるようにグリッド数が変化します。
完成版
まとめ
SkyWayのドキュメントが充実していたので、チュートリアルとサンプルを見ながら進めていくことで問題なく実装することができました。
ビデオ通話系アプリのデバッグではテスト用の相手端末を複数用意するのがしんどいところですが、JavaScript版のチュートリアルアプリを利用することで端末数をかせぐことができました。ブラウザのタブの数だけRoomにJoinできるので便利でした。このように、すぐに動作するサンプルが揃っていたのでストレスなく開発が捗りました。
参考になれば幸いです。