タイトル見ても何のこっちゃよくわかんない?(^_^;)
まえがき
諸般の事情でしばらく寝かせてた記事を公開することにしました(^_^)v
Androidで動画を生成保存するには、大きく分けて2つの方法があります。
1つはAndroidの初期からあるMediaRecorderクラスを使う方法で、もう1つはAndroid4.1(API>=16)で追加されたMedia Codec APIを使う方法です。MediaRecorder使う方法はネット上にも沢山資料が公開されているのでそちらをどうぞ。Media Codec APIを使う方法はMediaRecorderに比べると資料が少ないですが、このブログでも以前紹介させていただきました。例えばここらへんの記事になります。
音&動画の同時キャプチャがした〜い(その1)の冒頭にも書いてある通り、単純に音声と映像を録画するだけであればMedia Codec APIを使うメリットはあまりありません。Media Codec APIを使うならではのメリットの1つ・・・ようするにMediaRecorderでは出来ない事の1つを今回から数回に分けて紹介します。
MediaRecorderでは、API>=11でMediaRecorder#setCaptureRateメソッドが追加され、映像取り込み時の回数(フレームレート)を遅くすることが出来るようになりました。要はタイムラプス的な録画が出来るようになったってことなのですが、残念なことに1秒間に数フレームぐらいまでしかフレームレートを遅くすることが出来ません。
もっと遅くしたい、数分〜数時間毎に映像を取り込んで1つの動画ファイルにまとめたい。そんな事を考えたことのある人も居るとは思いますが、簡単には実現できません。そこでMedia Codec APIの出番です\(^o^)/(他の方法もありますが)
目標としては、
- 間欠的に撮影した動画を最終的に1つのファイルとして出力出来るようにする。
普通にMediaRecorderやMedia Codec APIを使う方法では、一度録画を停止すると追加して保存するなんてことは簡単には出来ません。でもそれを出来るようにしようじゃないかと(`・∀・´)!! - 1回の撮影あたりのフレーム数を1フレームだけ限定せず間欠的に任意のフレーム数をとりこめるような仕様とする。
これはまぁ例えば画面にタッチしている間だけ録画して最終的に1つのファイルに出力したいなぁってことです。純粋なタイムラプス動画を作成するためだけに作ったわけではないです。 - 途中でアプリが終了されても大丈夫にする。
3つ目は大事です。サービスとして実装する方法もあるのですが、例えば何時間かに1度しか動かないのに、アプリが常駐して動き続けてるあるいはリソースを使ってるなんてのは、リソースの限られたAndroid機にとっては良くないですよね。
と言う事で、動画の生成中に任意のタイミングでpause/resume出来るようなMediaRecorderもどきをMedia Codec APIを使って作ってみましたぁ\(^o^)/ 間欠動作そのもの・・・指定時間間隔毎に実行するなんて機能は含んでないです。タイマーでもサービスでも使って好きなタイミングを作って下さい。
概要
んじゃまぁ概要から行きまっせぇ〜(^o^)/
Media Codec APIを使って録音や録画をする場合には、普通はMediaCodecクラスを使ってエンコードした後そのデータをMediaMuxerクラスでファイルに書き込むと言う処理を行います。FFmpeg信者ならMediaMuxerの代わりにFFmpeg使うことも出来ます。
短時間の撮影でアプリは動きっぱなし、MediaCodecもMediaMuxerも動かしたままですむような用途であれば、例えばMediaCodecへ書き込む周期を調整&presentationTimeUsを適当な値にすればタイムラプス動画を生成することが出来ますが、何分・何十分・何時間も間が空くような間欠撮影だとそんなもったいないこと出来ません。と言うかいつ電話がかかってきてバックグランドへ回されたりローメモリーになってkillされてしまうかわからないスマホ/タブレットのアプリで長時間無駄にインスタンスを保ち続けるのはダメですよね。でもMediaMuxerクラスには既に存在している動画ファイルに追記していくような機能はありませんので、一旦終了してから何時間後かに新しいフレームを追加していくなんてことは出来ないのです。
一般的な動画ファイルフォーマット、例えばMP4とかでは内部にビットレートや画像サイズ、コーデックの種類等のメタデータ、映像トラックデータ、音声トラックデータ等を含み、ボックスと呼ばれる(規格によってはコンテナやチャンク、アトム、フレームなどとも呼ばれる)バイナリブロックが連なった構造をしています。そのために単純に映像データや音声データをフレーム毎に追記していくだけでは正しい動画フォーマットにならないのです。
むむぅ〜(´・ω・`) 自分で動画ファイルの内部データを直接書き換えるようなクラスを作るとか、一旦再生(デコード自体は不要)しながらMediaMuxerへ出力して元動画のデータが無くなったとこから新しいフレームを追加していくとか、FFmpeg使うとかすればばもちろん出来るのですが・・・
じゃあどうすんねん
じゃぁどうすんねんというと、いくつか実装方法は考えつくと思いますが、今回はアプリが終了しても大丈夫なようにするためにオレオレフォーマットの中間ファイルを生成して、順次追記して最後に1つの動画ファイルにまとめるって方法にしました。ようは、MediaCodecでのエンコード処理と、MediaMuxerでの動画ファイルへの出力処理を別々に分けてしまえってことですね。一般の動画ファイルでは誰が再生するかわからないので様々な情報を特定の形式で保存する必要がありますが、中間ファイルは自分しか読み書きしないので好きなように書き込むことが出来ます。
ちなみに今回の実装とは違いますが、純粋にタイムラプス動画を作るだけ・・・例えば定期的に1フレームだけ保存(ようはコマ撮り)するのであれば各フレームを静止画として保存しておいて、後からMediaCodecへ書き込んでエンコード&MediaMuxerでファイル出力するって方が簡単です。でもこれだと画面タッチ中だけ録画って事をしようとするとタッチ中に大量の静止画を生成する羽目になって色々問題が有るんだもん(^_^;)
じゃぁコードを
って言う前に1つ大事な事を書いておきましょう(^o^)/
Android DevelopersのMediaCodecの説明を見ると、
#stop()
Finish the decode/encode session, note that the codec instance remains active and ready to be start()ed again.
って書いてあります。これを読むと#stopを呼んでエンコード/デコード処理を停止させた後に#startを呼び出して再開出来るような気になりませんか? 自分はなりました。でも真っ赤な大嘘です。実際には再開できずIllegalStateException例外を生成します。
AOSPのコードを見ると、#stopを呼び出すとINITIALIZEDステート/UNINITIALIZEDステート(生成した直後と同じ状態)になっていました。一方で#startを呼び出すには、CONFIGUREDステート(#configureが正常終了した状態)になっている必要があるのです。つまり、現在のMediaCodecの実装で者#stopと#reset(API>=21)は実質的には同じになっているってことです。だめじゃん(●`ε´●)
と言う事で、#stopを呼び出した後は#configureからやり直さないとだめっです。
しかも#stopを呼び出してから少し時間を置いてからでないと#configureに失敗する機種もある・・・それもLogCatに出力されるだけで、#configureもそれ以降のメソッド呼び出しも例外すら生成せずに素通り(;_;) どおせぇってちゅうねん。
APIとしては再利用出来ると謳っているにも関わらず、#stopと#resetは実質的には同じ実装になってるってとこからしても実際には再利用は真剣にサポートする気の無い機能なのかもしれないですね。今回の実装では再利用はキッパリ諦めてreleaseしてしまうことにします。
その代わりといってはなんですが、今回の実装ではあまり短周期でのresume/pauseは不得意です。1〜数秒以上の間隔が開いているような場合がメインターゲットです。1秒に数フレーム以上保存したいのであれば普通にMediaRecorderとかMediaCodecを使って下さい。
ちなみに表向きは4ステートほどですが、実際のMediaCodec内では中間ステートも含めて11ステート定義されてあります。しかもメソッド呼び出しに伴う遷移以外に異常系の遷移もある(=実際には11ステートでは済まない)ので状態遷移表や状態遷移図にするのはかなり大変(T T) だからAndroid Developersを見てもMediaRecorderやMediaPlayerには状態遷移図があるのにMediaCodecには無いんだろうなぁきっと(-_-;)
今度こそコードを
と思ったけどやっぱり先にオレオレ中間ファイルの構造にしよう(^_^;)
ファイルの先頭から、ヘッダー、フォーマット、実際のフレームデータが並びます。
- ヘッダー
- コーデックの初期化データ。
MediaCodecの初期化に使用したMediaFormatをUTF文字列にシリアライズしたもの。
同じ条件で初期化したMediaCodecでないと1つの動画ファイルにまとめることが難しいので、初回初期化時に保存しておいて、2回目以降のMediaCodec初期化にも同じMediaFormatを使えるようにします。 - コーデックからの出力フォーマットデータ。
MediaCodec#getOutputFormatで取得したMediaFormatをUTF文字列にシリアライズしたもの。
H.264(AVC)等では動画ファイルに出力する際にMediaCodec#getOutputFormatで取得したMediaFormatに含まれるcsd-1, csd-2が必要になります(PPSとかが入ってます)。MediaCodec初期化用のMediaFormatと同じでこれも同じでないと困るので初回取得時に保存しています。 - フレームデータ
- フレームデータ
- …
ヘッダーは次の64バイトにしています。
- シーケンス番号, 32ビット符号付き整数
resumeする度にインクリメントしています。 - フレーム番号, 32ビット符号付き整数
- フレーム時刻, 64ビット符号付き整数
System#nanoTimeで取得した各フレーム保存時の時刻です。 - フレームサイズ, 32ビット符号付き整数。ヘッダーのサイズは不含
- フラグ, 32ビット符号付き整数
- 予約領域, 40バイト(32ビット整数で5つ分)
フレームデータはサイズ不定なので各フレーム毎に全てヘッダーをつけてその後に実際のビットストリームを書き込みます。
- ヘッダー
- 映像または音声のビットストリーム
作り始めはresumeの度に1つづつファイルを生成してましたが、最終的には1つのファイルに追記するだけにしました。ただし映像用と音声用は別ファイルです。
1つのファイルに追記していくんならシーケンス番号なんていらないんじゃないのって思うかもしれませんが、フレーム時刻を補正する際に必要です。シーケンス番号の変わり目でresume/pauseしたということなので、そこを起点にフレーム時刻をオフセットさせます。そうせずにもし単純にフレーム時刻をそのまま動画の各フレームの時刻にしてしまうと、仮にフレーム間が1時間空いていれば1時間もの間同じ映像が変わらず表示されてしまいます。
まぁシーケンス番号とフレーム番号をどちらか1個にまとめるのは出来ますけど最初の頃の名残&将来への備えと言う事で残しています。予約領域の40バイトも同じ理由ですが所詮はオレホレフォーマットなので辻褄さえ合わせていれば削除しても増やしてもOKですよ。
今度こそ今度こそコードを
と言ってもMediaCodec周りはいつもと変わらないんだよなぁ・・・後回しにしよう(^_^;)
代わりに中間ファイルから動画を生成する方をまず載せましょう。
どっか〜ん\(^o^)/
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
@Override public void run() { if (DEBUG) Log.v(TAG, "MuxerTask#run"); boolean isMuxerStarted = false; try { final MediaMuxer muxer = new MediaMuxer(mMuxerFilePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); if (muxer != null) try { int videoTrack = -1; int audioTrack = -1; DataInputStream videoIn = TLMediaEncoder.openInputStream(mMovieDir, TLMediaEncoder.TYPE_VIDEO, 0); // changeInput(null, 0, ++videoSequence); if (videoIn != null) { final MediaFormat format = TLMediaEncoder.readFormat(videoIn); if (format != null) { videoTrack = muxer.addTrack(format); if (DEBUG) Log.v(TAG, "found video data:format=" + format + "track=" + videoTrack); } } DataInputStream audioIn = TLMediaEncoder.openInputStream(mMovieDir, TLMediaEncoder.TYPE_AUDIO, 0); // changeInput(null, 1, ++audioSequence); if (audioIn != null) { final MediaFormat format = TLMediaEncoder.readFormat(audioIn); if (format != null) { audioTrack = muxer.addTrack(format); if (DEBUG) Log.v(TAG, "found audio data:format=" + format + "track=" + audioTrack); } } if ((videoTrack >= 0) || (audioTrack >= 0)) { if (DEBUG) Log.v(TAG, "start muxing"); ByteBuffer videoBuf = null; MediaCodec.BufferInfo videoBufInfo = null; TLMediaEncoder.TLMediaFrameHeader videoFrameHeader = null; if (videoTrack >= 0) { videoBufInfo = new MediaCodec.BufferInfo(); videoFrameHeader = new TLMediaEncoder.TLMediaFrameHeader(); } ByteBuffer audioBuf = null; MediaCodec.BufferInfo audioBufInfo = new MediaCodec.BufferInfo(); TLMediaEncoder.TLMediaFrameHeader audioFrameHeader = null; if (audioTrack >= 0) { audioBufInfo = new MediaCodec.BufferInfo(); audioFrameHeader = new TLMediaEncoder.TLMediaFrameHeader(); } byte[] readBuf = new byte[64 * 1024]; isMuxerStarted = true; int videoSequence = 0; int audioSequence = 0; long videoTimeOffset = -1, videoPresentationTimeUs = -MSEC30US; long audioTimeOffset = -1, audioPresentationTimeUs = -MSEC30US; muxer.start(); for (; mIsRunning && ((videoTrack >= 0) || (audioTrack >= 0)); ) { if (videoTrack >= 0) { if (videoIn != null) { try { videoBuf = TLMediaEncoder.readStream(videoIn, videoFrameHeader, videoBuf, readBuf); videoFrameHeader.asBufferInfo(videoBufInfo); if (videoSequence != videoFrameHeader.sequence) { videoSequence = videoFrameHeader.sequence; videoTimeOffset = videoPresentationTimeUs - videoBufInfo.presentationTimeUs + MSEC30US; } videoBufInfo.presentationTimeUs += videoTimeOffset; muxer.writeSampleData(videoTrack, videoBuf, videoBufInfo); videoPresentationTimeUs = videoBufInfo.presentationTimeUs; } catch (IllegalArgumentException e) { if (DEBUG) Log.d(TAG, String.format("MuxerTask:size=%d,presentationTimeUs=%d,", videoBufInfo.size, videoBufInfo.presentationTimeUs) + videoFrameHeader, e); } catch (IOException e) { videoTrack = -1; // end } } else { videoTrack = -1; // end } } if (audioTrack >= 0) { if (audioIn != null) { try { audioBuf = TLMediaEncoder.readStream(audioIn, audioFrameHeader, audioBuf, readBuf); audioFrameHeader.asBufferInfo(audioBufInfo); if (audioSequence != audioFrameHeader.sequence) { audioSequence = audioFrameHeader.sequence; audioTimeOffset = audioPresentationTimeUs - audioBufInfo.presentationTimeUs + MSEC30US; } audioBufInfo.presentationTimeUs += audioTimeOffset; muxer.writeSampleData(audioTrack, audioBuf, audioBufInfo); audioPresentationTimeUs = audioBufInfo.presentationTimeUs; } catch (IOException e) { audioTrack = -1; // end } } else { audioTrack = -1; // end } } } muxer.stop(); } if (videoIn != null) { videoIn.close(); } if (audioIn != null) { audioIn.close(); } } finally { muxer.release(); } } catch (Exception e) { Log.w(TAG, "failed to build movie file:", e); mIsRunning = false; synchronized (mSync) { if (mCallback != null) { mCallback.onError(e); } } } // remove intermediate files and its directory TLMediaEncoder.delete(mMovieDir); mBuilder.finishBuild(this); if (DEBUG) Log.v(TAG, "MuxerTask#finished"); synchronized (mSync) { if (mCallback != null) { mCallback.onFinished(mIsRunning && isMuxerStarted ? mMuxerFilePath : null); } } } |
実行内容は簡単で次の4つです。
- 普通にMediaMuxerを初期化します。
- 音声と映像のそれぞれの中間ファルを開きます。
- MediaCodecから#getOutPutFormatで出力フォーマットを取得する代わりにファイルから読み込んだ出力フォーマットを使って#addTrackします。
- 後は音声・映像それぞれの中間ファイルからフレーム毎にデータを読み込んでMediaMuxerへ書き込んでいくのを繰り返す
作り始めた時はもしかすると音声と映像の時刻を自前で同期しながら書き込まないとダメかもって思ってましたが、気にせずに順に書き込んでいって大丈夫でした。ただし前に書いたとおり音声・映像それぞれでresume/pasueした時の時刻補正だけはしておかないとダメです。
最初はresume/pause毎にファイルを生成したので面倒でしたが、ファイルを1つにしてからは意外とすんなり出来てしまって拍子抜けでした。もしかすると普通にMediaCodecでエンコードしながらMediaMuxerへ書き込んでいくよりも簡単かもしれないです。
と言う事で今回はおしまい。サンプルプロジェクトは近いうちにGitHubへ上げま〜す。
お疲れ様でした。
コメント
[…] 概要については以前の記事を参照して下さい。これ […]