こんにちは。WEAR部Androidチームの御立田です。先日、WEARチームでコーディネート動画を投稿できる機能を追加しました。
その際、WEARが提供する音楽リストから、ユーザーが好きな音楽を選択する機能を実装する必要がありました。今回は、動画ファイルの音楽データの変更をAndroidの端末上で行ったのでそこで得られた知見を共有したいと思います。
動画ファイル、音楽ファイルのフォーマットや、エンコード、デコードの設定は多岐にわたります。本投稿では、シンプルなパターンでやや抽象的に説明し、この投稿を読んだ人が「Androidにおいて動画の変換する時に何を調べれば良いのかがわかるようになる」を目的としています。
変換の仕様
・任意の動画ファイルの音楽データを、任意の音楽ファイルのデータに差し替える
・動画は指定のフォーマットで再エンコードする
・音楽は元の音楽データをそのまま利用する
・ただし、動画の長さの方が短い場合、音楽も動画の長さまでとする
Android端末上で動画を変換する流れ
1.MediaMetadataRetrieverで動画ファイルの長さを取得する
2.出力用の動画データを作成する
1.MediaExtractorで動画ファイルのデータ(エンコードされたままの状態)を読み込む
2.MediaCodec(decoder)でエンコードされているデータをデコードする
3.MediaCodec(encoder)でデコードされているデータを、指定のフォーマットにエンコードする
3.出力用の音楽データを作成する
1.MediaExtractorで音楽ファイルのデータ(エンコードされたままの状態)を読み込む
4.MediaMuxerを利用して、動画と音楽(2と3の出力)を混ぜ合わせて新しい動画ファイルを出力する
※エンコード、デコード処理に、Surfaceを利用することでより高速な変換が可能です。
流れとしては上記の通り、非常にシンプルです(実際の実装では2〜4はメモリの効率的な利用のために少しずつ読み込んで何度も繰り返しますが、わかりやすさ優先で各処理ごとに分けて説明しています)。
しかし、それでもMediaMuxerへのデータの渡し方にクセがあったり、エンコードやデコードの処理にクセがあったりしてなかなか一筋縄ではいきませんでした。
以降、各項目について疑似コードで説明していきますが、クセのある部分については多少詳しく説明していきたいと思います。
1. MediaMetadataRetrieverで動画ファイルの長さを取得する
MediaMetadataRetrieverを利用して動画ファイルの長さを読み込みます。特に難しいことはありません。音楽ファイルを動画の長さと合わせるために、後で設定します。
val retriever = MediaMetadataRetriever().apply {
setDataSource(inputVideoFileDescriptor)
}
val key = MediaMetadataRetriever.METADATA_KEY_DURATION
val videoDurationMs = retriever.extractMetadata(key)?.toLong()
2. 出力用の動画データを作成する
MediaExtractorで動画ファイルのデータ(エンコードされたままの状態)を読み込む
MediaExtractorを利用して、動画ファイルのデータを読み込みます。大まかな流れは下記の通りです。
- 1フレームの動画ファイルのデータを受け取れるByteBufferと、読み込んだByteBufferの状態を表すByteBufferInfoを準備する
- ByteBufferに1フレーム読み込む
- 読み込んだフレームの状態に応じてByteBufferInfoを設定する
- 最終フレームまで2〜3を繰り返す
取得したByteBufferやByteBufferInfoは次のデコーダーで利用します。
まずは動画ファイルのデータをExtractorで読み込めるようにExtractorの設定をします。
// 動画ファイルのtrackをextractorに設定する
// (動画のvideoFormatやmimeも後で利用するので取得しておく)
var trackIndex = 0
val videoTrackIndex: Int
val videoFormat: MediaFormat
val mime: String
while (true) {
val currentFormat = videoExtractor.getTrackFormat(trackIndex)
val currentMime = currentFormat.getString(MediaFormat.KEY_MIME) ?: continue
if (currentMime.startsWith("video/")) {
videoTrackIndex = trackIndex
videoFormat = currentFormat
mime = currentMime
break
}
trackIndex++
if (trackIndex >= videoExtractor.trackCount){
error("video track がありません")
}
}
videoExtractor.selectTrack(videoTrackIndex)
// 動画ファイルの1フレームのデータを読み込む
val sampleDataOutputByteBuffer = ByteBuffer.allocate(SAMPLE_DATA_BUFFER_CAPACITY)
val sampleDataOutputByteBufferInfo: BufferInfo
val readSampleDataSize = videoExtractor.readSampleData(sampleDataOutputByteBuffer, 0)
val isEmptySampleData = readSampleDataSize < 0
if (isEmptySampleData) {
// 最後まで読み込んだ場合は、BufferInfoを終了状態を表す内容に設定する。
sampleDataOutputByteBufferInfo = BufferInfo().apply {
offset = 0
size = 0
presentationTimeUs = 0
flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM
}
} else {
// 読み込んだsampleDataの状態を、BufferInfoに設定する
// offset: 今回は利用しないので0
// size: sampleDataのサイズ
// presentationTimeUs: extractorから現在のsampleDataの終了時間を取得して設定
// frags: keyFrameかどうかのフラグを設定
val isKeyFrame = videoExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
val bufferInfoFlags = if (isKeyFrame) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0
sampleDataOutputByteBufferInfo = BufferInfo().apply {
offset = 0
size = readSampleDataSize
presentationTimeUs = videoExtractor.sampleTime
flags = bufferInfoFlags
}
}
// videoExtractorの読み込み位置を次に進めておく
videoExtractor.advance()
MediaCodec(decoder)でエンコードされているデータをデコードする
MediaCodecを利用して、元動画ファイルのデータをデコードします。大まかな流れは下記の通りです。
- 元動画の形式をデコードできるデコーダーを作成
- MediaExtractorで取得した1フレームの情報(ByteBufferとByteBufferInfo)をデコーダーの入力用のキューに入れる
- デコーダーの出力用キューからデコードされたデータを取得する
- 2〜3を繰り返す
// MediaExtractorで取得した1フレームの情報(ByteBufferとByteBufferInfo)をデコーダーの入力用のキューに入れる
val decoder = MediaCodec.createDecoderByType(mime).apply {
configure(videoFormat, null, null, 0)
start()
}
// decoderの入力用のバッファの準備ができるまで待機。inputQueueのデータが処理されるのを待つ
val decoderInputBufferIndex: Int
while (true) {
val inputBufferIndex = decoder.dequeueInputBuffer(0)
if (inputBufferIndex >= 0) {
decoderInputBufferIndex = inputBufferIndex
break
}
delay(500.milliseconds)
}
// 入力用のバッファへデータを書き込んだ後にenqueueを行い、書き込んだデータのdecode処理が行われるようにする
val inputBuffer = decoder.getInputBuffer(decoderInputBufferIndex)
?: error { "デコーダー用の input buffer が取得できません" }
inputBuffer.clear()
inputBuffer.put(sampleDataOutputByteBuffer)
decoder.queueInputBuffer(
decoderInputBufferIndex,
0,
sampleDataOutputByteBufferInfo.size,
sampleDataOutputByteBufferInfo.presentationTimeUs,
sampleDataOutputByteBufferInfo.flags,
)
// デコーダーの出力用キューからデコードされたデータを取得する
val decoderOutputBufferInfo = BufferInfo()
val decoderOutputBufferIndex: Int
// dequeue の準備ができるまでまつ
while (true) {
val outputBufferIndex = decoder.dequeueOutputBuffer(decoderOutputBufferInfo, 0)
if (outputBufferIndex > 0) {
decoderOutputBufferIndex = outputBufferIndex
break
}
delay(500.milliseconds)
}
val decoderOutputBuffer = decoder.getOutputBuffer(decoderOutputBufferIndex)
?: error { "入力ビデオがデコードされたデータが入った ByteBuffer が取得できません" }
// output buffer をすぐに解放したいため、bufferをコピーしておく
val decodedSrcVideoByteBuffer = ByteBuffer.allocate(decoderOutputBuffer.capacity()).apply {
put(decoderOutputBuffer)
flip()
}
decoder.releaseOutputBuffer(decoderOutputBufferIndex, false)
MediaCodec(encoder)でデコードされているデータを、指定のフォーマットにエンコードする
MediaCodecを利用して、指定のフォーマットにエンコードします。大まかな流れは下記の通りです。
1.指定の形式にエンコードできるエンコーダーを作成
2.decoderの出力をエンコーダーの入力用のキューに入れる
3.エンコーダーの出力用キューからエンコードされたデータを取得する
4.2〜3を繰り返す
続きはこちら