追記:android-transcoderを参考に、iOSでいうGPUImageのような動画にエフェクトを掛けたりできるようにしたライブラリも登場したみたいです!
Androidで、Mp4にウォータマークつけたり、フィルターかけたりする
Androidにはまともな動画圧縮の方法がなく、デカすぎる動画をそのまま3Gで送っていつまでたってもアップロードが終わらない事態になってしまいます。
どうにかして圧縮したい気持ちになった人用のライブラリを用意したのでその仕組みについて解説します。
Android 4.3 (API 18)以上で対応します。(後の罠で書きますが、解像度などに一部制限があります)
※AndroidとiOS両方で扱える動画フォーマットについては別記事をどうぞ
背景
そもそもFFmpegじゃだめなの
ライセンスやら特許やらネイティブバイナリやら様々な問題を抱えることになります。
- mp4のエンコードに必須なlibx264をコンパイルするとGPLになってしまう
- GPLを回避するには商用ライセンスを購入する必要がある
- mp4で使われるH.264コーデックのエンコーダーを同梱・配布してエンコードすると特許使用料を支払う必要が出る可能性がある
- 「インターネット上に無料で公開される動画」などの条件を満たさないと、MPEG LAという団体と特許使用の契約と使用料の支払いを行う必要がある
- そもそもAndroid用ネイティブコードのクロスコンパイルとかだるいぞ・・?
MediaCodecとかあるらしいじゃん
iOSだと雑にフレームワークにぶん投げたらmp4返ってくるけど、Androidのは話が全く違うのです。はっきり言って、これはC言語のライブラリのラッパーです。MediaCodec.cppとか普通にあって、これのラッピングなんです。
データのストリームを受け付けるコーデックのインスタンス(ネイティブでもある)がいて、それにByteBuffer or Surfaceでデータを順次流し込むと、出力のByteBuffer or Surfaceからデータが流れてくる、という仕様です。
しかも、MediaCodecはH.264という生の動画ストリームを扱うだけで、mp4ファイルから動画を読みだしたり、mp4ファイルに書き出したり、ストリーム配信可能化したりするのは全部別途やらなくちゃいけないわけです。
じゃあどうする
どうにもならなかったので、主に下記のサイトやAndroidフレームワークそれに対応するC++コードと、Compatibility Test Suiteのソースコードを読みまくって、MediaCodecなどをなんとか動画圧縮のために使えないか検討しました。特に下記サイトは本当に役に立ちましたm(_ _)m
http://bigflake.com/mediacodec/
CTSには動画圧縮に近いデコード→エンコードの処理をやっているものがいくつかあり、それを参考にしました。
https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/ExtractDecodeEditEncodeMuxTest.java
https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecodeEditEncodeTest.java
GoogleのGrafikaというサンプルアプリの中には綺麗なmp4エンコーダの実装があり、理解に役立ちました。
https://github.com/google/grafika/blob/f3c8c3dee60153f471312e21acac8b3a3cddd7dc/src/com/android/grafika/VideoEncoderCore.java
C++のコードはlibstagefrightというもので、MediaCodecなどはこれをラッピングしたものになっています。
https://android.googlesource.com/platform/frameworks/av/+/jb-mr2-release/media/libstagefright
android-transcoderの実装と各コンポーネント
主なアーキテクチャ
- MediaExtractor: mp4ファイルから動画と音声のストリームを取り出します。
- MediaCodec: H.264のエンコーダー、デコーダーです。
- Surface: 描画用のバッファです。SurfaceTextureと組み合わせるとMediaCodecの入出力に使うことができます。
- TextureRender: OpenGLを使い、デコーダーから出力された画素を描画します。
- MediaMuxer: 動画と音声のストリームを組み合わせてmp4ファイルを作ります
- qtfaststart-java: mp4ファイルをストリーミング可能にします(MOOVの再配置、これをしないと全部ダウンロードしてから再生)。C言語のqt-faststartをJavaに移植しました。これはandroid-transcoderライブラリではやってくれないので別途叩きます。
MediaExtractor
mp4ファイルから動画や音声といったストリームを取り出します。
動画・音声のようなストリームをtrack、MediaCodecで扱える一単位をsampleと表現しています。
advance()を呼び出すと次のsampleに移動し、いくつかのインスタンスメソッドを通してsampleのtrack番号、プレゼンテーションタイム、バイナリデータを取り出すことができます。
getSampleTrackIndex()して動画のストリームの情報だった場合は、readSampleData()で取れるデータを動画用のデコーダーに振り分けてあげれば良いという感じですね。
罠が一点有り、Android 5.0以降で急にMediaFormatインスタンスに"rotation-degrees"とかいうundocumentedな値が追加され、それをそのままMediaCodecに渡すとデコード結果が回転された状態になります。
https://github.com/google/ExoPlayer/issues/91#issuecomment-92346082
MediaCodec
コーデックの抽象化で、動画・音声をデコード・エンコードすることができます。このライブラリではH.264(動画)のコーデックのみ使っています。
内部的にはAndroid側のネイティブコードが呼び出されていて、隔離されたスレッドで動作しているようです。入力用・出力用に複数のバッファを持っていて、クライアント側はビジーでないバッファを要求し読み書きすることで、データをやりとりすることができます。
動画の非圧縮データのやりとり(デコーダの出力、エンコーダの入力)にはGPU描画用のバッファであるSurfaceを使うこともできます、というかこれをしないとめちゃくちゃ面倒。後述しますが、OpenGL ESを経由することで、デコーダーの出力を解像度を下げて描画しなおしてからエンコーダーにデータを流すことができます。
最近公式のドキュメントがかなりまともになったようです。バージョンごとにインタフェースがかなり違うのが最悪の曲者。
Android 4.3 (API 18)からSurfaceが使えるようになり、それ以前はただのByteBufferしか扱えませんでした。ByteBufferの場合はそもそもYUV色空間のbyte上での配置方法うんぬんを考える必要があり、正直無理ゲーです。なので、Surfaceを使えるAndroid 4.3以上が今の所対応可能なバージョンです。
エンコーダーの扱い方はGrafikaサンプルアプリのVideoEncoderCoreがシンプルでわかりやすいです。
デコーダーとエンコーダーを組み合わせた例は、android-transcoderのVideoTrackTranscoderを参照してください。
Decoderの場合
上の図では「decode」とOutput Surfaceしか書いていないですが、実際はdecoderの場合でもOutput Bufferが存在します。そのため、ものすごく大雑把に書くと下記のような処理になります。
int result = mDecoder.dequeueOutputBuffer(mBufferInfo, timeoutUs); // 処理済みのOutput Bufferを取得
mDecoder.releaseOutputBuffer(result, true); // 2つめの引数はSurfaceにtextureを描画するかどうか
mDecoderOutputSurfaceWrapper.awaitNewImage(); // 描画されたtextureの到着待ち
mDecoderOutputSurfaceWrapper.drawImage(); // textureをOpenGLで描画
mEncoderInputSurfaceWrapper.setPresentationTime(mBufferInfo.presentationTimeUs * 1000);
mEncoderInputSurfaceWrapper.swapBuffers(); // 現在描画されている内容をEncoderに送信
OutputSurface, InputSurface, TextureRender
圧縮する際にはもちろん解像度を下げる必要があり、それはOpenGL ESを使って実現されています。Androidフレームワーク標準の、画面描画用のバッファであるSurfaceと、SurfaceからOpenGL ESテクスチャを得るSurfaceTexture、そしてOpenGL ESの描画処理を扱うカスタムのコードを使って実装されています。
OutputSurfaceは、デコーダーで使う出力用Surfaceを作成・管理し、書き込まれたフレームをSurfaceTextureで取得し、TextureRenderを通してOpenGL ESのプログラム(テクスチャを描画する位置と描画内容を決める)で「レンダリングコンテキスト」に描画します。この際に縮小したテクスチャを描画することで解像度を下げます。なおOpenGLで動画にフィルタをかけることもできるかもしれません。
InputSurfaceはエンコーダーから入力用Surfaceを受け取り、OutputSurfaceと同じ「レンダリングコンテキスト」に紐づけるようです。バッファの内容をエンコーダーに送り出す処理と、プレゼンテーションタイム(フレームが表示される時間)の管理も行っています。
使っているCompatibility Test Suiteのコード
ExtractDecodeEditEncodeMuxTestなどで使われていたOuputSurfaceとInputSurface、TextureRenderを雑にもらってきて使っているというのが正直なところです・・。つまりこのライブラリの描画部分はCTSのソースコードを使って作られています。
MediaMuxer
動画と音声のストリームを混ぜて、mp4ファイルを作ります。trackを追加してwriteSampleData()するだけのシンプル仕様です。あと位置情報とか動画の回転情報もつけることができます。
しかし罠が一点だけあります。それは、MediaMuxerの処理を開始するにはMediaCodecのgetOutputFormat()で得られるMediaFormat(下記参照)を渡す必要がありますが、MediaCodecにデータをいくばくか流し込んでINFO_OUTPUT_FORMAT_CHANGED
が飛んでくるのを待ってからじゃないとgetできないという点です。動画と音声の両方のストリームのフォーマットが決まってからじゃないとMediaMuxerを開始できないので、MediaCodecから出てくるデータを(一瞬)キューイングして全てのフォーマット確定を待ってから流すようにしています(ライブラリのQueuedMuxerクラスを参照)。
※もしかすると、同じプレゼンテーションタイムの動画と音声のフレームがバイトストリーム上であまりに遠ざかってしまうとダメなような気もします・・。ここについてはちゃんとわかっていないというのが現状です。
MediaFormat
上の図には描きませんでしたが、動画・音声ストリームのメタデータを格納するという点で非常に重要なクラスです。
- エンコーダー、デコーダーで扱うフォーマットのmimeタイプやwidth/height
- エンコーダに渡すビットレート、フレームレート、Iフレームの間隔
- H.264のSPS・PPS情報
- MediaExtractorからデコーダーへ、またエンコーダーからMediaMuxerへ情報を引き渡す用
特にSPS・PPSは圧縮に使用したパラメータなどを持っていて重要です。勝手に作ったMediaFormatのインスタンスをデコーダーやMediaMuxerに渡してしまうと、これらのメタデータがなくてエラーになってしまうと思われます。
罠情報
iOSと違ってCTSテストやVideo Encoding Recommendationsにないフォーマットは雑に対応されていることがあるようで、エンコーダーにバグがある場合もあるようです。
- iOSのAVAssetExportPreset960x540(1920x1080の愚直な1/4サイズ)と揃えたところ、Nexus 4で出力がぶっ壊れた。
- INFO_OUTPUT_FORMAT_CHANGED待ちがダルかったので、フォーマット確定後にデコーダーの状態をリセットするflush()を叩いてストリームの最初からやり直したところ、Samsung端末で出力動画がぶっ壊れた。 https://github.com/ypresto/android-transcoder/issues/8
- 解像度に対してビットレートがあまりに低すぎるとぶっ壊れる場合がある模様・・?
- 現状Baseline Profile(H.264の最も簡単な圧縮方法)以外にエンコードすることは不可能(C++のコードで強制的にBaselineにセットしている箇所がある・・けどどこだったっけ・・)。
- 入力にBaseline Profile以外の動画が突っ込まれた時の挙動が未知数(ダウンロードやSDカードでのやりとりなどしない限り起きないはず)。
- 上記の"rotation-degrees"が勝手に追加されてAndroid 5.0以上で突然動画が歪んだ https://github.com/ypresto/android-transcoder/issues/3
- MediaMuxerはストリーミング可能mp4ファイル生成機能があるみたいだけど、無理な条件でやっていてほとんど有効にならない http://stackoverflow.com/q/24000026/1474113
- MediaExtractorから出てきた動画ストリームをそのままMediaMuxerに流すと動画が壊れる(原因不明)
以上を踏まえると、
- Video Encoding Recommendationsにない解像度に圧縮する場合は、端末上で動画の圧縮前後に差異がないか(ぶっ壊れてないか)テストする仕組みを作る(この機能はいつか実装したいと思っていましたがやってないのでPR welcomedです)
- flush()みたいな無理なやり方をしない
- 新しいOSが出たら動作が大丈夫かチェックしてみる
- Baseline Profile以外を扱わない
- ストリーミング可能化はqtfaststart-javaを使って別途やる
- MediaCodecを一切介す必要がない場合は元のファイルのまま使う
がベストプラクティスです。がんばって圧縮していきましょう・・!
あとよく見るとライブラリでフレームレートを30にセットすると勝手に29.97でエンコードされるようでよくわからないもんです・・。