動画の音声を任意の別データに差し替えてエンコードする方法

ogp

こんにちは。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. 1フレームの動画ファイルのデータを受け取れるByteBufferと、読み込んだByteBufferの状態を表すByteBufferInfoを準備する
  2. ByteBufferに1フレーム読み込む
  3. 読み込んだフレームの状態に応じてByteBufferInfoを設定する
  4. 最終フレームまで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を利用して、元動画ファイルのデータをデコードします。大まかな流れは下記の通りです。

  1. 元動画の形式をデコードできるデコーダーを作成
  2. MediaExtractorで取得した1フレームの情報(ByteBufferとByteBufferInfo)をデコーダーの入力用のキューに入れる
  3. デコーダーの出力用キューからデコードされたデータを取得する
  4. 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を繰り返す
// decoderの出力をエンコーダーの入力用のキューに入れる

// 出力するビデオのフォーマットを設定し、encoderを作成する
val outputFormat = MediaFormat.createVideoFormat("video/avc", 1920, 1080).apply {
    setInteger(MediaFormat.KEY_BIT_RATE, 2 * 1000 * 1000)
    setInteger(MediaFormat.KEY_FRAME_RATE, 30)
    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 3)
    setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible)
}
val encoder = MediaCodec.createEncoderByType(mime).apply {
    configure(outputFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
    start()
}

// encoderの入力用のバッファの準備ができるまで待機。inputQueueのデータが処理されるのを待つ
val encoderInputBufferIndex: Int
while (true) {
    val inputBufferIndex = encoder.dequeueInputBuffer(0)
    if (inputBufferIndex >= 0) {
        encoderInputBufferIndex = inputBufferIndex
        break
    }
    delay(500.milliseconds)
}

// 入力用のバッファへデータを書き込んだ後にenqueueを行い、書き込んだデータのencode処理が行われるようにする
val inputBuffer = encoder.getInputBuffer(encoderInputBufferIndex)
inputBuffer.clear()
inputBuffer.put(srcVideoDecodedByteBuffer)
encoder.queueInputBuffer(
    encoderInputBufferIndex,
    0,
    srcVideoDecodedByteBufferByteBufferInfo.size,
    srcVideoDecodedByteBufferByteBufferInfo.presentationTimeUs,
    srcVideoDecodedByteBufferByteBufferInfo.flags,
)
// エンコーダーの出力用キューからエンコードされたデータを取得する
val encoderOutputBufferInfo = BufferInfo()
val encoderOutputBufferIndex: Int
// dequeue の準備ができるまでまつ
while (true) {
    val outputBufferIndex = encoder.dequeueOutputBuffer(encoderOutputBufferInfo, 0)
    if (outputBufferIndex > 0) {
        encoderOutputBufferIndex = outputBufferIndex
        break
    }
    delay(500.milliseconds)
}

val encoderOutputBuffer = encoder.getOutputBuffer(encoderOutputBufferIndex) ?: error { "出力ビデオ用の、エンコードされたデータが入った ByteBuffer が取得できません" }

// outputBufferをすぐに解放したいため、bufferをコピーしておく
val encodedDstVideoByteBuffer = ByteBuffer.allocate(encoderOutputBuffer.capacity()).apply {
    put(encoderOutputBuffer)
    flip()
}
encoder.releaseOutputBuffer(encoderOutputBufferIndex, false)

3. 出力用の音楽データを作成する

MediaExtractorで音楽ファイルのデータ(エンコードされたままの状態)を読み込む

// 音楽ファイルのtrackをextractorに設定する
// (音楽のaudioFormatやmimeも後で利用するので取得しておく)
var trackIndex = 0
val audioTrackIndex: Int
val audioFormat: MediaFormat
val mime: String
while (true) {
    val currentFormat = audioExtractor.getTrackFormat(trackIndex)
    val currentMime = currentFormat.getString(MediaFormat.KEY_MIME) ?: continue
    if (currentMime.startsWith("audio/")) {
        audioTrackIndex = trackIndex
        audioFormat = currentFormat
        mime = currentMime
        break
    }
    trackIndex++
    if (trackIndex >= audioExtractor.trackCount){
        error("audio track がありません")
    }
}
audioExtractor.selectTrack(audioTrackIndex)
// 音楽ファイルのデータを読み込む
val sampleDataOutputByteBuffer = ByteBuffer.allocate(SAMPLE_DATA_BUFFER_CAPACITY)
val srcEncodedByteBuffer: ByteBuffer
val srcEncodedByteBufferInfo: BufferInfo
val readSampleDataSize = audioExtractor.readSampleData(sampleDataOutputByteBuffer, 0)
val isEmptySampleData = readSampleDataSize < 0
val isOverVideoDuration: Boolean = audioExtractor.sampleTime >= videoDurationMs * 1000
if (isEmptySampleData) {
    // 最後まで読み込んだ場合は、BufferInfoを終了状態を表す内容に設定する
    srcEncodedByteBuffer = ByteBuffer.allocate(0)
    srcEncodedByteBufferInfo = BufferInfo().apply {
        offset = 0
        size = 0
        presentationTimeUs = 0
        flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM
    }
} else {
    if (isOverVideoDuration) {
        // 動画の長さを超えた場合は、BufferInfoを終了状態を表す内容に設定する
        srcEncodedByteBuffer = ByteBuffer.allocate(0)
        srcEncodedByteBufferInfo = BufferInfo().apply {
            offset = 0
            size = 0
            presentationTimeUs = 0
            flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM
        }
    } else {
        srcEncodedByteBuffer = ByteBuffer.allocate(sampleDataOutputByteBuffer.capacity()).apply {
            put(sampleDataOutputByteBuffer)
            flip()
        }
        val isKeyFrame = audioExtractor.sampleFlags and MediaExtractor.SAMPLE_FLAG_SYNC != 0
        val bufferInfoFlags = if (isKeyFrame) MediaCodec.BUFFER_FLAG_KEY_FRAME else 0

         // 読み込んだsampleDataの状態を、BufferInfoに設定する
         // offset: 今回は利用しないので 0
        // size: sampleDataのサイズ
        // presentationTimeUs: extractor から現在のsampleDataの終了時間を取得して設定
        // frags: key frame かどうかのフラグを設定
        srcEncodedByteBufferInfo = BufferInfo().apply {
            offset = 0
            size = readSampleDataSize
            presentationTimeUs = audioExtractor.sampleTime
            flags = bufferInfoFlags
        }
    }
}
// 次のsampleに進めておく
audioExtractor.advance()

4. MediaMuxerを利用して、動画と音楽(2と3の出力)を混ぜ合わせて新しい動画ファイルを出力する

  1. MediaMuxerを作成する
    1. エンコーダから取得できるMediaFormatを設定する
    2. 音楽ファイルのMediaFormatを設定する
    3. MediaMuxerをスタートする
  2. MediaMuxerに出力用のデータを書き込む
    • エンコーダから出力された動画データを書き込む
    • MediaExtractorで取得した音楽データを書き込む
  3. 2を繰り返す
// MediaMuxerを作成する
val muxer = MediaMuxer(outputPath, OutputFormat.MUXER_OUTPUT_MPEG_4)

// エンコーダから取得できるMediaFormatを設定する
// * encoder.outputFormatは、encoderから最初のoutputが行われる直前のタイミングで取得できるようになる
val videoTrackIndex = muxer.addTrack(encoder.outputFormat)

// 音楽ファイルのMediaFormatを設定する。そのまま利用するので最初に取得したaudioFormatをそのまま設定する
val audioTrackIndex = muxer.addTrack(audioFormat)

// MediaMuxerをスタートする
// * start前に、書き込みを行うトラックが追加されている必要がある。今回の場合は、videoTrackとaudioTrack
// * 一度startするとトラックの追加はできない
muxer.start()
// MediaMuxerに出力用のデータを書き込む
// * エンコーダから出力された動画データを書き込む
muxer.writeSampleData(videoTrackIndex, videoByteBuffer, videoByteBufferInfo)

// * MediaExtractorで取得した音楽データを書き込む
muxer.writeSampleData(audioTrackIndex, audioByteBuffer, audioByteBufferInfo)

まとめ

以上が動画や音楽のエンコード、デコード、差し替えの流れになります。

流れとしてはシンプルですが、実装にあたっていくつかハマった点があるので、それぞれの工程ごとに列挙します。

  • 動画ファイル、音楽ファイルデータ読み込み時
    • MediaExtractorのselectTrackを忘れずに実行する
      • selectTrackしなくてもエラーにならず空のデータが取得されるだけの挙動となり、原因究明に時間がかかってしまった
  • エンコード、デコード時
    • releaseOutputBufferを忘れずに実行する
      • encoder,decoderの結果が出力できないので結果として処理が止まり無限ループに陥ってしまった
  • 差し替え時(MediaMuxer利用時)
    • 動画と音楽を1つのファイルに書き込むときは、事前に全てのデータを書き込める状態にしておく必要がある
      • MediaMuxerを開始する前(start()を呼ぶ前)に、書き込みたい動画と音楽をaddTrackしておかなければならない
      • start()は一度実行したらstop()しても再度start()を呼ぶことはできず1つずつトラックを書き込んでいくことができない
    • 動画や音楽のデータをaddTrackする時に指定するMediaFormatは実際にencoderから取得できるものを利用する必要がある
      • encoderからMediaFormatを取得できるようになるタイミングは最初のエンコード結果が出力される直前
        • MediaFormatの取得を待ってからmuxerの書き込みを開始させた
      • 自分でencoder作成時に指定したMediaFormatではないので注意が必要
        • encoderから取得できるMediaFormat以外でaddTrackした場合、書き込み正常に行われない

これらを知っておくことで、うまく変換できなかった時に「何を調べたら良いのか」の足がかりになると思います。この情報が、動画の音声を任意の別データに差し替えてエンコードしたいと思った人の一助になれれば幸いです。

最後までご覧いただきありがとうございました。ZOZOでは、各種エンジニアを採用中です。ご興味のある方は以下のリンクからご応募ください。

corp.zozo.com

カテゴリー