Webカメラの動画をキャプチャリング(その1)

車輪の再発見みたいな?」から、 「Webカメラの動画をキャプチャリング(その1)」を学ぼう。

Android端末に接続した外部カメラからの映像をライブストリーミングするアプリ

はじめに

Android端末に接続したUVC対応カメラ(USB接続のWebカメラ等)からの映像と音声をRTMP対応の動画配信サーバーへ、H.264(映像)+AAC(音声)でライブストリーミングするためのアプリを公開しました\(^o^)/ アプリ内課金対応の無料です。まだ不具合があるかもしれないのでプレビュー版と言う事で。

Google Playのリンクはこちら「UVCStreamer」

Wowza Streaming EngineとRED5へはライブストリーミングできることを確認しています。RTMPに対応している動画配信サーバーの多くへライブストリーミング出来るのではないかと思います。また動画配信サービスとしてはUTREAMへもライブストリーミングできました。時々休止してますが現在もベランダのトマトをUSTREAMへライブストリーミング中です^^

リンクはこちらUSTREAM

Nexus7(2013, Android 5.1.1)からUSTREAMへライブストリーミングしてそれをNexus5のUSTREAMアプリで表示している写真:

この写真を撮った時よりトマトはもう少し大きくなってピンポン球より少し大きなサイズになりました。

ストリーミングの設定

ストリーミングに関する設定は設定画面にあります。設定項目としては、「接続先設定」,「ビットレート」,「動画FPS」の3項目あります。

最低限ストリーミング先のURLを入力する必要がありますが、URLの入力は設定画面の「接続先設定」をタップして表示されるダイアログで入力してください。複数登録して切り替えることも可能です。ちなみにこの「接続先設定」ダイアログは、現在はプレビュー画面の長押しでも表示することが出来ます(長押しでの接続先設定表示機能はもしかすると将来のバージョンでは削除するかもしれないです)。

USTREAM

USTREAMのライブ配信用URLは、「ダッシュボード」→「チャンネル情報」→「ライブ配信設定」→「エンコーダ設定」の右にある”設定”の文字をクリックすると表示される「エンコーダ設定」画面に記載されています。 「手動エントリ」の「RTMP URL」項目と「ストリームキー」項目が必要となります。面倒ですが手入力をお願いしますm(__)m XMLファイルから読み込めるようになると良いなぁ0^^ 「接続先設定」ダイアログの「接続先URL」へ「RTMP URL」を、「ストリーム名」に「ストリームキー」を入力してください。「RTMP URL」と「ストリームキー」をスラッシュ(/)で繋いで1つにして入力しても構いませんが自動で分割します。

ストリーミング開始・終了

画面下部中央の赤いボタン(UsbWebCamera/UsbWebCameraProでの動画録画ボタンと同じ)でストリーミングを開始・終了できます。 URLが正しくてストリーミング開始できれば時間が表示されます。

ネットワークの状態や端末の性能によってはストリーミング開始するまでに暫く掛かることが有ります。

2015/06/08追記:

もしUSTREAMへストリーミング出来ないようであれば、URLとストリームキーが間違ってないかを確認してそれでもだめなら、ブラウザからUSTREAMブロードキャスターへ一度ログインしてからもう一度試して見てください。

対応機器

いつものことですが、MediaTek製, Rockchip製およびAllwinner製のチップセットを搭載した端末の多くはUSBホスト機能が正常に動作しないためアプリが動作しません。

これらの機種では端末がフリーズしたり再起動することがあります。またそれ以外の機種でもUSBからの出力電流に制限があったり鋭敏な設定になっている機種があります。

接続可能なカメラ等は以前から公開しているUsbWebCamera/UsbWebCamerProと同じですが、標準状態では解像度を640×480以下に制限していますので、640×480以下の解像度に対応していないカメラ等は標準では使用できません。アプリ内課金で解像度制限は解除可能です。

以前の記事に記載したとおり、接続方法によって正常に動かない端末・カメラの組み合わせが有ります。可能な限りセルフパワーのUSB2.0ハブを経由して繋いでください。OTG Yケーブル(補助電源供給可能なOTGケーブル)でも使える場合がありますが、この場合にもできればカメラとOTG Yケーブルの間にUSB2.0ハブを挟んでください(この場合にはセルフパワーのSUBハブでなくても大丈夫です)。

アプリ内課金

アプリ内課金画面には設定画面から移動できます(下の方に有ります)。アプリ内課金のアイテムとしては次の4種類です。

  • 解像度制限の解除
  • 標準:640×480以下 → 無制限

  • ストリーミング時のビットレート制限解除
  • 標準:400kbps以下 → 8000Mbps

    ただし実際に送信可能なビットレートはネットワークの通信速度(主にアップストリームの通信速度)・端末の性能(特に動画エンコードの性能)に強く依存しますので、設定した通りにはならないことが有ります。特に古い機種だと解像度にもよりますが50001Mbps程度で頭打ちになってしまう事が多いようです。例えば手持ちのNexus7(2012, Android4.4.4)だとLAN内の動画配信サーバーとの接続で測定しても500kbps強ぐらいまでしか出ませんでた。

  • 広告表示解除
  • 標準:広告あり → 広告表示なし

  • ローカル録画
  • 現在はまだ実装していないので表示されるだけで無効です。でも結構面倒なので実装しないかも(-_-;)

その他

UsbWebCamera/UsbWebCameraProでも最近実装しましたが、音声モニター表示が出来ます。音声を有効にすると画面右下にバー表示されます。実装には少し苦労しましたが自分的には結構お気に入り^^v

「設定画面」→「エキスパート設定」→「音声モニターを有効」をOFFにするか、音声を無効にすると表示されません。

Webカメラからh264動画を取得したいその1

あっ、どんなWebカメラからでもh264動画を取得できるわけではないからね。

最近のWebカメラであれば殆どはMJPEGには対応してるけど、一部はYUV2にしか対応してません。 そしてh264対応となると高価な極少数のWebカメラしか対応してません。

自分が調べた範囲内ではUSB接続のUVC規格対応でh264のハードウエアエンコード対応なのは

  • ロジクール C930e
  • これは自分も持ってます。高かったぁ↓↓ 1.5万円ぐらいでした。

  • ロジクールC920t
  • 誰かプレゼントしてくださいm(_ _)m

    末尾のアルファベットはぼちぼち変わりますね。

    詳しくは次回以降として、C930eとはh264対応の仕方が違うらしいので2016年になったら買うかも(^^)

  • ロジクールC920-C
  • これはCisco認定モデルっぽいですね。

  • バッファロー BSW50KM01Hシリーズ
  • バッファローのHPだと在庫限りとなってます。

    誰かプレゼントしてくださいm(_ _)m

  • バッファロー BSW50KM02Hシリーズ
  • これはマニュアルフォーカスみたい。しかもh264は1280×720のみ対応(´・ω・`) ってことは対応するのはなんか色々面倒くさそうなヨ・カ・ン(´・ω・`) 誰かお年玉代わりにくださいm(_ _)m

  • クリエイティブ LIVE! CAM CONNECT HD
  • 誰かプレゼントしてくださ0いm(_ _)m

ぐらいなのかなぁ。まぁ探せばまだ他にもあるかもしれないけど。でも安物だと1000円しないWebカメラの中にあってどれも1万円超えの高級品(@@)

USB/UVCに限定しなければネットワークカメラでh264ハードウェアエンコード対応なのはぽちぽちありますね。

で、h264対応なら何が嬉しいかと言うと、カメラ側で重たいh264エンコード処理を行ってくれるので、使う側の負荷が下がる…あとはMJPEGはフレーム毎に圧縮しているだけなのに対してh264では複数フレーム間に渡って圧縮処理を行うので1フレームあたりのデータ量が少なくてすみます。平均的にはMJPEGの数分の1ぐらいかな。

もっともプレビュー表示するならデコードしないといけないので結局一緒やんという意見も^^;。後は複数フレーム間に渡って圧縮されているので何らかの理由で1フレームでも飛んでしまうと次のI-Frameが来るまで映像がかなり乱れてしまうとか、圧縮率画像の内容等によってはブロックノイズがよく見えたりとかまぁ世の中いいことだけではないですけど。

はてさてカメラ自体の話は置いといて、h264ハードウエアエンコードできるWebカメラが存在する&自分はAndroidにUSB経由でWebカメラを接続して表示するライブラリとかアプリを公開してる、ってことは…じゃんじゃかじゃんじゃん。Webカメラからh264動画を取得した0い(^o^)/ そう思って自分がc930eを買ったのは去年のことでした。でもWebカメラからh264動画を取得する方法について調べてみると…ネット上には殆ど情報はありません(´・ω・`) USB.orgにある規格(英語)がほぼ唯一と言っていい情報源ですね。

しかもですねぇ、あれを読んだことがある人にはわかると思いますが色々と一筋縄ではいかないのです。 _| ̄|○ il||li YUV2(やMJPEG)ならチョイチョイって感じなのに。

でもやりましたよぉ0\(^o^)/ 少なくともc930eのh264動画に関しては。詳しくは次回以降に書くつもりですが、UVC規格のh264対応のごく極一部にしか対応してないので他のカメラだとどうなんやろって感じでまだまだ改善の余地がありますが。 しか0も残念ながら?現時点ではオレオレライブラリでのみ対応でlibusb/libuvcではh264関連のディスクリプタの解析すらできません。

ということで今回は前振りだけでm(_ _)m 年末だし忙しいんだもん。明日も仕事の打ち合わせが ; 今年もお疲れ様でした。

Webカメラからh264動画を取得したいその2

前回は単なる前振りで中身ショボっって思った人もいるかも(汗)

今回からはがっつり中身だよぉ(^^)v もっとも一度もUVCの規格書見たこと無い人やUVC関連のプログラムをしたことのない人にはさっぱりだったりして^^;

UVC規格での映像フォーマットについて

UVC規格でのh.264の取り扱いについて書く前に、UVC規格での映像フォーマットについて簡単に書きます。

USB規格に準拠した機器では、その機器が対応している機能についてデバイスディスクリプタと呼ばれるバイナリデータを読み取ることが出来ます。デバイスディスクリプタは大きく分けて次の3種類があります。

  • USB機器共通の標準ディスクリプタ(Standard Descriptor)
  • デバイスクラス固有のクラスディスクリプタ(Class Specific Descriptor)
  • ベンダーが独自に定義したベンダーディスクリプタ(Vendor Specific Descriptor)

それぞれのディスクリプタは更な細かく分類されるのですが、UVC(=USB Video Class)機器が対応している映像フォーマットはクラスディスクリプタのペイロードフォーマットディスクリプタ(Payload Format Descriptor)とビデオフレームディスクリプタ(Video Frame Descriptor)を解析することで取得することが出来ます。

UVC1.5規格のペイロードフォーマットディスクリプタでは次の11種類が定義されています。

UVC1.5 Specification:Table3-16 Payload Format Descriptorから
  1. Uncompressed Video
  2. MJPEG Video
  3. MPEG1-SS
  4. MPEG2-PS
  5. MPEG-2 TS
  6. H.264
  7. VP8(UVC1.1では未定義)
  8. SMTPE VC1
  9. MPEG-4 SL
  10. DV
  11. Vendor Defined

また、UVC1.5規格のビデオフレームディスクリプタでは次の4種類が定義されています。

UVC1.5 Specification:Table3-17 Defined Video Frame Descriptor Resourcesから

Uncompressed

MJPEG

Generic Frame-Based

H.264(UVC1.1では未定義)

VP8(UVC1.1では未定義)

それぞれの定義の詳細は省略することにしますが、UVC規格に対応したほぼすべてのWebカメラで非圧縮フォーマット(Uncompressed Video)に対応しています。また多くの場合MJPEG Videoフォーマットにも対応しています。

非圧縮フォーマット(Uncompressed Video)としてはYUY2, NV12, M420, I420の4種類が定義されています。つまり非圧縮フォーマットと言いながら実際にはピクセル単位で圧縮されたフォーマットなのです。とは言うものの例えばYUY2フォーマットの場合は1ピクセルあたり2バイト必要で解像度が1280×720では1フレームあたり約1.800メガバイト、1920×1080だと約4メガバイトを毎フレーム転送することになります。ですので解像度の大きな映像を高フレームレートで転送するのには向きません。

そこで使われるのがMJPEG Videoフォーマットで、フレーム毎にJPEG圧縮した映像フォーマットになります。設定によりますが少なくとも非圧縮フォーマットの1/401/5程度のデータサイズになるのでより高解像度の映像を高フレームレートで転送することが出来ます。

ところで、先ほどの2つのリストには「H.264」と項目がありました。なのでこれだけを見るとYUY2やMJPEGと同じようにフォーマットディスクリプタとフレームディスクリプタを解析すればH.264で映像取得できるんやな、と思ってしまうかもしれません。しか0しそれは甘い考えなのです。チンスコウのように甘いのです。

UVC規格でのH.264対応について

例えば自分も持っているLogicool C930eはH.264のハードウエア圧縮に対応しています。H.264を使えばMJPEGよりも更に数分の1のデータサイズで転送できます。なのでぜひとも使いたいところです。というかそのためにわざわざC930eを買ったのでした。

しかしど0んなに頑張ってもH.264用のフォーマットディスクリプタもフレームディスクリプタも見つかりません(´・ω・`)

Logicoolの専用ドライバを入れたPCでしかアクセス出来ない悪いやつなのか、いえいえそんな事はありません。実はUVC規格でのH.264対応方法には2種類あるのです。

  1. YUY2やMJPEGと同様にフォーマットディスクリプタとフレームディスクリプタを使う方法
  2. UVC1.5規格でのH.264対応

  3. MJPEGのペイロードに埋め込んで転送する方法
  4. UVC1.1規格でのH.264対応

C930eの場合には2つ目のMJPEGのペイロードに埋め込んで転送する方法でのみH.264で圧縮された映像を取得できるのでした。 しかもこの方法はかなり面倒なことをしないとH.264で圧縮された映像を取得できるようにならないのです(´・ω・`)

続く^^/

Webカメラからh264動画を取得したいその3

前回の記事でUVC規格対応の機器からH.264で圧縮された映像を取得する方法が2種類あること、そのうちの1つがMJPEGペイロードに埋め込んで転送する方法であること、しかもその方法でH.264映像を取得するのが面倒であると書きました。

今回からは何がそんなに面倒なのかってあたりを書きたいと思います。面倒な部分は大きく3つあります。

UVC1.1でのH.264対応ではエクステンションユニットへアクセスがする必要

通常のWebカメラからの映像取得では次のような順序で処理します。ちょっと省略し過ぎ?(汗)。

通常のWebカメラからの映像取得手順
  1. カメラと接続(ファイルオープン)する
  2. デバイスディスクリプタを解析。フォーマットディスクリプタとフレームディスクリプタからカメラが対応している映像フォーマット、解像度、フレームインターバル(フレームレート)等を取得する
  3. 使用したい条件をカメラとネゴシエーションする
  4. カメラから映像を受け取る
  5. 必要なだけ映像を受け取ったらカメラからの映像ストリームを停止する
  6. カメラから切断(ファイルクローズ)する

一方UVC1.1機器からH.264映像を取得しようとすると、フォーマットディスクリプタにもフレームディスクリプタにも定義がないのでUVC機器がH.264に対応しているのかどうか、対応しているのであればどんな解像度やフレームレートが使用できるのかがわかりません。UVC1.1でのH.264対応の場合はエクステンションユニットディスクリプタの解析とエクステンションユニットへのアクセスによって必要な情報を取得しネゴシエーションする必要があります。

MJPEGペイロードに多重化されたH.264映像の取得手順
  1. カメラと接続(ファイルオープン)する
  2. デバイスディスクリプタを解析。フォーマットディスクリプタとフレームディスクリプタからカメラが対応している映像フォーマット、解像度、フレームインターバル(フレームレート)等を取得する
  3. デバイスディスクリプタを解析。エクステンションユニットディスクリプタが存在する場合にはそれがH.264エクステンションユニットかどうかをguidExtensionCodeを使って確認する。
  4. H.264エクステンションユニットが存在する場合には使用可能なH.24 configurationを問い合わせる
  5. H.264エクステンションユニットへネゴシエーションを行う
  6. これによってMJPEGペイロードにH.264ペイロードが多重化されて転送されてくるようになります

  7. 使用したい条件(MJPEG)をカメラとネゴシエーションする
  8. カメラから映像(MJPEG)を受け取る
  9. MJPEGペイロードを解析してH.264ペイロードを取り出す
  10. 取り出したH.264ペイロードはよきにはからいたもうれ。表示するならごにょごにょしてからMediaCodecのデコーダーに放り込んでSurfaceへ描画させればOKです

  11. 必要なだけ映像を受け取ったらカメラからの映像ストリームを停止する
  12. H.264エクステンションユニットの設定をクリアする
  13. カメラから切断(ファイルクローズ)する

2倍近い手順が必要になります。面倒くさぁ(´・ω・`)

エクステンションユニットディスクリプタの解析

通常通りデバイスディスクリプタを解析するとビデオコントロールインターフェースディスクリプタの下にエクステンションユニットディスクリプタが見つかる事があります。 エクステンションディスクリプタの定義はこんな感じ(Cの構造体としての定義)

typedef struct uvc_extension_unit_descriptor {
	uint8_t bLength;
	uint8_t bDescriptorType;
	uint8_t bDescriptorSubType;
	uint8_t bUnitID;
	uint8_t guidExtensionCode[16];
	uint8_t bNumControls;
	uint8_t bNrInPins;
	uint8_t baSourceID[0];
	uint8_t bControlSize;
	uint8_t bmControls[0];
	uint8_t iExtension;
} __attribute__((__packed__)) extension_unit_descriptor_t;

UVC1.1機器がH.264に対応しているかどうかは、この構造体のguidExtensionCodeフィールドの値をチェックすることでわかります。

// Codec (H.264) Control: A29E7641-DE04-47e3-8B2B-F4341AFF003B
static const uint8_t GUID_UVCX_H264_XU[] = {
	0x41, 0x76, 0x9e, 0xa2,
	0x04, 0xde,
	0xe3, 0x47,
	0x8b, 0x2b,
	0xf4, 0x34, 0x1a, 0xff, 0x00, 0x3b};

// エクステンションユニットディスクリプタの先頭へのポインタ(別途取得)
const extension_unit_descriptor_t *desc;

// memcmpでguidExtensionCodeを比較する
if (!memcmp(GUID_UVCX_H264_XU, desc->guidExtensionCode, 16)) {
	// H.264のエクステンションユニットが見つかった\(^o^)/
	...
}

実際の解像度等はH.264エクステンションユニットに対して問い合わせを行うことで取得できます。こんな感じのコードになりまする。

typedef struct _uvcx_video_config_probe_commit_t
{
	uint32_t	dwFrameInterval;
	uint32_t	dwBitRate;
	uint16_t	bmHints;
	uint16_t	wConfigurationIndex;
	uint16_t	wWidth;
	uint16_t	wHeight;
	uint16_t	wSliceUnits;
	uint16_t	wSliceMode;
	uint16_t	wProfile;
	uint16_t	wIFramePeriod;
	uint16_t	wEstimatedVideoDelay;
	uint16_t	wEstimatedMaxConfigDelay;
	uint8_t		bUsageType;
	uint8_t		bRateControlMode;
	uint8_t		bTemporalScaleMode;
	uint8_t		bSpatialScaleMode;
	uint8_t		bSNRScaleMode;
	uint8_t		bStreamMuxOption;
	uint8_t		bStreamFormat;
	uint8_t		bEntropyCABAC;
	uint8_t		bTimestamp;
	uint8_t		bNumOfReorderFrames;
	uint8_t		bPreviewFlipped;
	uint8_t		bView;
	uint8_t		bReserved1;
	uint8_t		bReserved2;
	uint8_t		bStreamID;
	uint8_t		bSpatialLayerRatio;
	uint16_t	wLeakyBucketSize;
} __attribute__((__packed__)) uvcx_video_config_probe_commit_t;

uvcx_video_config_probe_commit_t config;
UVCDevice *dev = (UVCDevice *)device;
// 最大値を取得
int result = dev->query_config(config, desc->bUnitID, true, REQ_GET_MAX);
if (!result) {
	// 最大値を取得できた
	h264->addConfig(config);
	const int num_configs = config.wConfigurationIndex; // C930eはなぜかいつもゼロ
	// configurationを全て取得
	for (int i = 0; i < num_configs; i++) {
		result= dev->query_config(config, desc->bUnitID, true, REQ_GET_CUR);
		if (!result)
			h264->addConfig(config);
	}
	// 現在値を取得
	result = dev->query_config(config, desc->bUnitID, true, REQ_GET_CUR);
}

ただですねぇUVC1.1規格ではwConfigurationIndexの値は10maxとなっていて、最大値を取得すると対応しているconfigurationの個数がわかるという記述があるのですが、c930eで最大値を取得(GET_MAX)するとwConfigurationIndexがいつも0が返ってくるのです(゜゜) UVC規格にのっとれば対応するconfigurationはゼロ個…つまり対応していないってことになってしまいます。

しかも、現在値(GET_CUR)を呼ぶたびにwConfigurationIndexがインクリメントして対応している数分を順に返すと書いてあるのですが、c930eでは複数回現在値を読み込んでも(GET_CUR)全て同じ値が返ってきます。また最小値(GET_MIN)も同じ値です。

まぁUVC機器…に限らずUSB機器全般でこういった規格外のバギーなディスクリプタを返す機器は沢山、ほんっとに沢山あるのでconfigurationが1個あるとみなして後続の処理を行います。ちなみに、先ほどのコードで最大値取得に成功した際にh264->addConfigを呼んでいるのはこういったバギーデバイス対応のworkaroundです。本来は後のforループ内でaddConfigするだけでいいはずなんですけどね。

前々回の記事で書きましたがH.264対応機器は非常に少ないのとどれも高価なのでなかなか全部確認するってわけにも行かずこういった振る舞いが普通なのかどうか、あるいは違ったworkaroundが必要なのかがよくわからないのが現実です。

しかし困りました、configurationが1つしかありません。これでは解像度・ビットレート等の最大値しかわかりません。いろいろ試した結果c930eの場合はMJPEGで対応している解像度であればh.264でも大丈夫なようです。

でも、バッファーローのBSW50KM02のようにH.264ハードウェアエンコードは1280×720のみってなカメラもあるようなので、ネゴシエーションに失敗した時のフォールバック処理を考えとかないといけないですね。

自分の場合は、解像度は指定値に固定してH.264→H.264多重化モード(これがUVC1.1でのH.264対応)→MJPEG→YUY2の順にネゴシエーションを試みるようにしています。

次回はH.264エクステンションユニットのネゴシエーションです。

まだまだ続く^^/

お疲れ様でした。

音&動画の同時キャプチャがしたい(その1)

本当は、MediaCodec+MediaMuxerを使って音&動画の同時キャプチャがしたい。

前置き

内蔵カメラ・内蔵マイクからの録音録画であれば、あえてMediaCodec+MediaMuxerを使う必要はあまりありません。

MediaRecorderってのを使えば簡単に出来ます・・・たぶん。ステートマシンが複雑だったり、公式ドキュメントに載ってない設定をしないと駄目だったりと色々苦労しますけど。

MediaRecorderの自分にとっての一番の問題は、公式には内蔵カメラ以外のビデオソースに対応してない事かな。だってUSB接続のカメラから録画したいじゃん・・・AOSPと一緒にビルドするとか裏技は有るけど・・・

ちなみに、WebのAndroid DevelopersのAPIレファレンスでMediaRecorder.VideoSourceを表示させると、ビデオソースとしては、

  • CAMERA
  • DEFAULT

の2つしか載っていません。DEFAULTとCAMERAの違いって何ねんな。でもEclipseからSDKのAPIレファレンスのMediaRecorder.VideoSourceを表示させると、ビデオソースとして

  • CAMERA
  • DEFAULT
  • SURFACE

の3つがしらっと表示されます。・・・えっSURFACE?hideになってたんじゃないん?いっいつの間に・・・知ってた?

ちなみにSDKのAPIレファレンスの説明文はこれ。対応APIレベルの欄は空欄です。SDKは23っす。前から載ってたかどうかは記憶にございません。

public static final int SURFACE

  • Surface video source
  • Using a Surface as video source.

    This flag must be used when recording from an android.hardware.camera2.CameraDevice source.

    When using this video source type, use getSurface() to retrieve the surface created by MediaRecorder.

    Constant Value: 2 (0x00000002)

ん?そもそもMediaRecorder#getSurfaceなんて有ったっけ?APIレファレンスにはWebのにもSDKのにも載ってない。

SDKのソースも覗いてみた。

    /**
     * Defines the video source. These constants are used with
     * {@link MediaRecorder#setVideoSource(int)}.
     */
    public final class VideoSource {
      /* Do not change these values without updating their counterparts
       * in include/media/mediarecorder.h!
       */
        private VideoSource() {}
        public static final int DEFAULT = 0;
        /** Camera video source */
        public static final int CAMERA = 1;
        /** @hide */
        public static final int GRALLOC_BUFFER = 2;
    }

やっぱりhideじゃん。しかも#getSurfaceなんて無いし。ぬか喜びでしたね。これはもしかして予告なんかな?でもきっと間違えて載せとるんやろな。

と言う事でやっぱりMediaCodec+MediaMuxerで行きましょう(^o^)/

閑話休題

めでたく?MediaCodec+MediaMuxerで行くとなったわけですが、実際に実装しようとすると情報が余りありません。

録画だけってのはGrafika(GitHub:Grafikaへのリンクはこれ)も含めて色々とあります。MediaCodec+MediaMuxerでの録音ってのは数は少ないけど少しは見つかります(録音だけなら普通はMediaRecorderが定番だからでしょう)。でも、MediaCodec+MediaMuxerで録音・録画ってどないすんねんってグルグル先生に聞いても、同じ様などないしたらええんや0って質問が出てくるだけで、実際にこないしたらええねんってのはよく判りませんでした。

と言う事で、まずは挙動確認のために、USBのカメラはほっといて内蔵カメラ・内蔵マイクからMediaCodec+MediaMuxerを使ってMPEG4で出力するサンプルを作ってみました。音声はAAC、動画はAVC(H.264)でエンコードすることにします。

前置きが長っ(^_^;)いや、実際のコードはもっともっと長いけどね。

全体の構成

ソースは数も沢山あるしそれぞれそれなりに長いので全体の構成を先に載せてしまいましょう。もっともいきなり構成を考えてからプログラムをしたわけでは無いですけど。

スレッド(MediaCodec/MediaMuxer周りのみ)

まずはスレッドです。これが一番大事です。

MediaCodecMediaMuxer1MediaCodec/MediaMuxerに関係するスレッドは、下の図の用になります。sourceスレッドとdrainスレッドは兼用できますが、効率よく処理するには別々に分けた方が良いと思います。なお、それぞれのTrackスレッドとWriteスレッドはMediaMuxerが内部で生成してくれるので自分で生成する必要はありません。ただ非同期で書き出しているんだってことは覚えとかないと駄目なのであえて図に加えています。


MediaCodec/MediaMuxerに関係するスレッド

なお、実際にはMediaCodec内部もスレッドで動いているでしょうし、内蔵カメラ・内蔵マイクから録音録画出来るアプリとして動くようにするには、これ以外にもカメラ操作用のスレッドとかプレビュー描画用のスレッドとかも必要になります。

それぞれの役割は読んで字の如しですが、

  • sourceスレッド
  • データの生成しMediaCodecへ入力するスレッド

  • drainスレッド
  • MediaCodecがエンコードしたデータを取り出してMediaMuxerへ入力するスレッド

  • track/writerスレッド(MediaMuxerが内部で生成)
  • drainスレッドが入力したデータをMPEG4の形式でファイルとして書き出すスレッド

クラス構成(MediaCodec/MediaMuxer周りのみ)

エンコード部分(スレッドでいうところのsourceとdrainを含むクラス)とMediaCodecのインスタンスは音声と映像用にそれぞれ別々に生成しなければなりません。なので普通は2つクラスを作るところなのですが、音声と映像の処理で似ているところが沢山あります。なので、共通の抽象クラスと(MediaEncoder)、音声用の子クラス(MediaAudioEncoder)、映像用の子クラス(MediaVideoEncoder)の3クラスに分割しました。

一方書き出し側については、APIレファレンスを見てもよく判りませんが、MediaMuxerのインスタンスは1つしか生成せず、音声と映像の両方から書き込むことになります。

MediaMuxerのインスタンスを音声用・映像用のエンコードクラスに直接渡すことも出来なくはないのですが、少し厄介なのがMediaMuxerのスタート方法・MediaMuxer&エンコーダーの終了方法なのです。このためMediaMuxerのラッパークラス(MediaMuxerWrapper)を1つ作成しました。この部分は後ほどソースと一緒に説明することにします。

ちなみに、MediaMuxerは単独で生成して、エンコーダークラスのコンストラクタへ引き渡し、抽象クラスでMediaMuxerWrapper相当の処理を実装することも出来ますが、MediaMuxerWrapperとしてクラスを1つ増やして旗振り役を任せた方がわかりやすのではないかと思います。

と言う事で次からはいよいよコードの嵐です。


と言う事でコード

まずはエンコード処理の共通の抽象クラスMediaEncoderから行きましょう(^o^)/

MediaEncoder
package com.serenegiant.encoder;

public abstract class MediaEncoder implements Runnable {
	private static final boolean DEBUG = true;	// TODO set false on release
	private static final String TAG = "MediaEncoder";

	protected static final int TIMEOUT_USEC = 10000;	// 10[msec]   
	protected static final int MSG_FRAME_AVAILABLE = 1;
	protected static final int MSG_STOP_RECORDING = 9;

	public interface MediaEncoderListener {
		public void onPrepared(MediaEncoder encoder);
		public void onStopped(MediaEncoder encoder);
	}
	
	protected final Object mSync = new Object();
	/**
	 * Flag that indicate this encoder is capturing now.
	 */
	protected volatile boolean mIsCapturing;
	/**
	 * Flag to request stop capturing
	 */
	protected volatile boolean mRequestStop;
	/**
	 * Flag that indicate encoder received EOS(End Of Stream)
	 */
	protected boolean mIsEOS;
	/**
	 * Flag the indicate the muxer is running
	 */
	protected boolean mMuxerStarted;
	/**
	 * Track Number
	 */
	protected int mTrackIndex;
	/**
	 * MediaCodec instance for encoding
	 */
	protected MediaCodec mMediaCodec;				// API >= 16(Android4.1.2)
	/**
	 * Weak refarence of MediaMuxerWarapper instance
	 */
	protected final WeakReference mWeakMuxer;
	 /**
	 * BufferInfo instance for dequeuing
	 */
	private MediaCodec.BufferInfo mBufferInfo;		// API >= 16(Android4.1.2)
	 /**
	 * Handler of encoding thread
	 */
	private EncoderHandler mHandler;
	protected final MediaEncoderListener mListener;

	public MediaEncoder(MediaMuxerWrapper muxer, MediaEncoderListener listener) {
		if (listener == null) throw new NullPointerException("MediaEncoderListener is null");
		if (muxer == null) throw new NullPointerException("MediaMuxerWrapper is null");
		mWeakMuxer = new WeakReference(muxer);
		muxer.addEncoder(this);
		mListener = listener;
		synchronized (mSync) {
			// create BufferInfo here for effectiveness(to reduce GC)
			mBufferInfo = new MediaCodec.BufferInfo();
			// wait for Handler is ready
			new Thread(this, getClass().getSimpleName()).start();
			try {
				mSync.wait();
			} catch (InterruptedException e) {
			}
		}
	}

	/**
	 * the method to indicate frame data is soon available or already available
	 * @return return true if encoder is ready to encod.
	 */
	public boolean frameAvailableSoon() {
//		if (DEBUG) Log.v(TAG, "frameAvailableSoon");
		synchronized (mSync) {
			if (!mIsCapturing || mRequestStop) {
				return false;
			}
			mHandler.sendEmptyMessage(MSG_FRAME_AVAILABLE);
		}
		return true;
	}

	/**
	 * Message loop for encoding thread
	 * Prepare Looper/Handler and execute message loop and wait terminating.
	 */
	@Override
	public void run() {
		// create Looper and Handler to access to this thread
		Looper.prepare();
		synchronized (mSync) {
			mHandler = new EncoderHandler(this);
			mRequestStop = false;
			mSync.notify();
		}
		Looper.loop();

		if (DEBUG) Log.d(TAG, "Encoder thread exiting");
		synchronized (mSync) {
			mIsCapturing = false;
			mRequestStop = true;
			mHandler = null;
		}
	}

	/*
	 * prepareing method for each sub class
	 * this method should be implemented in sub class, so set this as abstract method
	 * @throws IOException
	 */
	/*package*/ abstract void prepare() throws IOException;

	/*package*/ void startRecording() {
		if (DEBUG) Log.v(TAG, "startRecording");
		synchronized (mSync) {
			mIsCapturing = true;
			mRequestStop = false;
			mSync.notifyAll();
		}
	}

	/**
	 * the method to request stop encoding
	 */
	/*package*/ void stopRecording() {
		if (DEBUG) Log.v(TAG, "stopRecording");
		synchronized (mSync) {
			if (!mIsCapturing || mRequestStop) {
				return;
			}
			mRequestStop = true;	// for rejecting newer frame
			mSync.notifyAll();
			// request endoder handler to stop encoding 
			mHandler.sendEmptyMessage(MSG_STOP_RECORDING);
			// We can not know when the encoding and writing finish.
			// so we return immediately after request to avoid delay of caller thread
		}
	}

//********************************************************************************
//********************************************************************************
	/**
	 * Method to request stop recording
	 * this method is called from message hander of EncoderHandler
	 */
	private final void handleStopRecording() {
		if (DEBUG) Log.d(TAG, "handleStopRecording");
		// process all available output data
		drain();
		// request stop recording
		signalEndOfInputStream();
		// process output data again for EOS signal
		drain();
		// release all related objects
		release();
	}

	/**
	 * Release all releated objects
	 */
	protected void release() {
		if (DEBUG) Log.d(TAG, "release:");
		try {
			mListener.onStopped(this);
		} catch (Exception e) {
			Log.e(TAG, "failed onStopped", e);
		}
		mIsCapturing = false;
		if (mMediaCodec != null) {
			try {
				mMediaCodec.stop();
				mMediaCodec.release();
				mMediaCodec = null;
			} catch (Exception e) {
				Log.e(TAG, "failed releasing MediaCodec", e);
			}
		}
		if (mMuxerStarted) {
			final MediaMuxerWrapper muxer = mWeakMuxer.get();
			if (muxer != null) {
				try {
					muxer.stop();
				} catch (Exception e) {
					Log.e(TAG, "failed stopping muxer", e);
				}
			}
		}
		mBufferInfo = null;
	}

	protected void signalEndOfInputStream() {
		if (DEBUG) Log.d(TAG, "sending EOS to encoder");
		// signalEndOfInputStream is only avairable for video encoding with surface
		// and equivalent sending a empty buffer with BUFFER_FLAG_END_OF_STREAM flag.
//		mMediaCodec.signalEndOfInputStream();	// API >= 18
		encode(null, 0, getPTSUs());
	}

	/**
	 * Method to set byte array to the MediaCodec encoder
	 * @param buffer
	 * @param length length of byte array, zero means EOS.
	 * @param presentationTimeUs
	 */
	protected void encode(byte[] buffer, int length, long presentationTimeUs) {
		if (!mIsCapturing) return;
		int ix = 0, sz;
		final ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers();
		while (mIsCapturing && ix < length) {
			final int inputBufferIndex = mMediaCodec.dequeueInputBuffer(TIMEOUT_USEC);
			if (inputBufferIndex >= 0) {
				final ByteBuffer inputBuffer = inputBuffers[inputBufferIndex];
				inputBuffer.clear();
				sz = inputBuffer.remaining();
				sz = (ix + sz < length) ? sz : length - ix; 
				if (sz > 0 && (buffer != null)) {
					inputBuffer.put(buffer, ix, sz);
				}
				ix += sz;
//				if (DEBUG) Log.v(TAG, "encode:queueInputBuffer");
				if (length <= 0) {
					// send EOS
					mIsEOS = true;
					if (DEBUG) Log.i(TAG, "send BUFFER_FLAG_END_OF_STREAM");
					mMediaCodec.queueInputBuffer(inputBufferIndex, 0, 0,
						presentationTimeUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
					break;
				} else {
					mMediaCodec.queueInputBuffer(inputBufferIndex, 0, sz,
					presentationTimeUs, 0);
				}
			} else if (inputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
				// wait for MediaCodec encoder is ready to encode
				// nothing to do here because MediaCodec#dequeueInputBuffer(TIMEOUT_USEC)
				// will wait for maximum TIMEOUT_USEC(10msec) on each call
			}
		}
	}

	/**
	 * drain encoded data and write them to muxer
	 */
	protected void drain() {
		if (mMediaCodec == null) return;
		ByteBuffer[] encoderOutputBuffers = mMediaCodec.getOutputBuffers();
		int encoderStatus, count = 0;
		final MediaMuxerWrapper muxer = mWeakMuxer.get();
		if (muxer == null) {
//			throw new NullPointerException("muxer is unexpectedly null");
			Log.w(TAG, "muxer is unexpectedly null");
			return;
		}
LOOP:		while (mIsCapturing) {
			// get encoded data with maximum timeout duration of TIMEOUT_USEC(=10[msec])
			encoderStatus = mMediaCodec.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
			if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
				// wait 5 counts(=TIMEOUT_USEC x 5 = 50msec) until data/EOS come
				if (!mIsEOS) {
					if (++count > 5)	
					break LOOP;		// out of while
				}
			} else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
				if (DEBUG) Log.v(TAG, "INFO_OUTPUT_BUFFERS_CHANGED");
				// this shoud not come when encoding
				encoderOutputBuffers = mMediaCodec.getOutputBuffers();
			} else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
				if (DEBUG) Log.v(TAG, "INFO_OUTPUT_FORMAT_CHANGED");
				// this status indicate the output format of codec is changed
				// this should come only once before actual encoded data
				// but this status never come on Android4.3 or less
				// and in that case, you should treat when MediaCodec.BUFFER_FLAG_CODEC_CONFIG come.
				if (mMuxerStarted) {	// second time request is error
					throw new RuntimeException("format changed twice");
				}
				// get output format from codec and pass them to muxer
				// getOutputFormat should be called after INFO_OUTPUT_FORMAT_CHANGED otherwise crash.
				final MediaFormat format = mMediaCodec.getOutputFormat(); // API >= 16
				mTrackIndex = muxer.addTrack(format);
				mMuxerStarted = true;
				if (!muxer.start()) {
					// we should wait until muxer is ready
					synchronized (muxer) {
						while (!muxer.isStarted())
						try {
							muxer.wait(100);
						} catch (InterruptedException e) {
							break LOOP;
						}
					}
				}
			} else if (encoderStatus < 0) {
				// unexpected status
				if (DEBUG) Log.w(TAG, "drain:unexpected result from encoder#dequeueOutputBuffer: " + encoderStatus);
			} else {
				final ByteBuffer encodedData = encoderOutputBuffers[encoderStatus];
				if (encodedData == null) {
					// this never should come...may be a MediaCodec internal error
					throw new RuntimeException("encoderOutputBuffer " + encoderStatus + " was null");
				}
				if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
					// You should set output format to muxer here when you target Android4.3 or less
					// but MediaCodec#getOutputFormat can not call here(because INFO_OUTPUT_FORMAT_CHANGED don't come yet)
					// therefor we should expand and prepare output format from buffer data.
					// This sample is for API>=18(>=Android 4.3), just ignore this flag here
					if (DEBUG) Log.d(TAG, "drain:BUFFER_FLAG_CODEC_CONFIG");
					mBufferInfo.size = 0;
				}

				if (mBufferInfo.size != 0) {
					// encoded data is ready, clear waiting counter
					count = 0;
					if (!mMuxerStarted) {
						// muxer is not ready...this will prrograming failure.
						throw new RuntimeException("drain:muxer hasn't started");
					}
					// write encoded data to muxer(need to adjust presentationTimeUs.
					mBufferInfo.presentationTimeUs = getPTSUs();
					muxer.writeSampleData(mTrackIndex, encodedData, mBufferInfo);
					prevOutputPTSUs = mBufferInfo.presentationTimeUs;
				}
				// return buffer to encoder
				mMediaCodec.releaseOutputBuffer(encoderStatus, false);
				if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
					// when EOS come.
					mMuxerStarted = mIsCapturing = false;
					break;      // out of while
				}
			}
		}
	}

	/**
	 * previous presentationTimeUs for writing
	 */
	private long prevOutputPTSUs = 0;
	/**
	 * get next encoding presentationTimeUs
	 * @return
	 */
	protected long getPTSUs() {
		long result = System.nanoTime() / 1000L;
		// presentationTimeUs should be monotonic
		// otherwise muxer fail to write
		if (result < prevOutputPTSUs)
			result = (prevOutputPTSUs - result) + result;
		return result;
	}

	/**
	 * Handler class to handle the asynchronous request to encoder thread
	 */
	private static final class EncoderHandler extends Handler {
		private final WeakReference mWeakEncoder;

		public EncoderHandler(MediaEncoder encoder) {
			mWeakEncoder = new WeakReference(encoder);
		}

		/**
		 * message handler
		 */
		@Override 
		public void handleMessage(Message inputMessage) {
			final int what = inputMessage.what;
			final MediaEncoder encoder = mWeakEncoder.get();
			if (encoder == null) {
				Log.w(TAG, "EncoderHandler#handleMessage: encoder is null");
				return;
			}
			switch (what) {
			case MSG_FRAME_AVAILABLE:
				encoder.drain();
				break;
			case MSG_STOP_RECORDING:
				encoder.handleStopRecording();
				Looper.myLooper().quit();
				break;
			default:
				throw new RuntimeException("unknown message what=" + what);
			}
		}
	}
}

どっか0ん\(^o^)/いきなり長い・・・その代わりaudio用・video用のsubclassは少し短い・・・はずです。コメントを取れば1/3ぐらいのはずだから内容的にはそう長くもないですけど。

この中で、sourceスレッドの核となるのが#encode、drainスレッドの核となるのが#drain、drainスレッドを操作するためのHandlerがEncoderHandlerになります。#encodeはMediaCodecへbyte配列でデータを入力するためのメソッドですが、今回のVideo入力に関してはSurface経由で行うのでaudioでしか使いません。 MediaCodecの初期化とMediaCodecへの入力についてはaudioとvideoで処理が結構違うので抽象クラスには含めずサブクラスで定義を行います。逆に言うとここで定義してしまっているdrainスレッド・drain処理についてはサブクラスでは殆ど気にする必要がありません。初期化メソッドは後々の事を考えてabstractとして宣言だけはしておきます。

この中で一番キーとなるのは、#darin内のMediaCodec.INFO_OUTPUT_FORMAT_CHANGEDが来た時の処理です。

MediaCodecについてはcreate > configure > start > [データ書き込み] > 終了指示(EOSセット) > stop > releaseと順に実行すればいいだけですが、MediaMuxerについてはそう簡単ではありません。 MediaMuxerは、 create > addTrack(MediaCodecからoutputFormat取得してセット&トラック番号を取得) > start > [write] > stop > releaseの順に実行しなければなりません。 まず初めに問題となるのは、「MediaCodecからoutputFormat取得」です。MediaCodec#getOutputFormatを呼び出すだけやろって思ったあなた、甘いです。

  1. #getOutputFormatはMediaCodec.INFO_OUTPUT_FORMAT_CHANGEDが来るまでは呼び出すことが出来ない
    MediaCodec.INFO_OUTPUT_FORMAT_CHANGEDは一番最初のエンコードが完了するまでは来ないので、MediaMuxer#startを呼び出す前にMediaCodecへデータの入力行ってそのデータのエンコードが完了して初めて#getOutputFormatを呼び出すことが出来るのです。ややこしい(´・ω・`)。
    MediaCodec.INFO_OUTPUT_FORMAT_CHANGEDが来る前に#getOutputFormatを呼び出すとクラッシュします。
    ちなみに、コメントに少し書いてありますが、Andoird4.3未満ではMediaCodec.INFO_OUTPUT_FORMAT_CHANGEDは決して来ません。つまり、#getOutputFormatのAPIレファレンスにはAPI16以上と書いてありますが、Andoird4.3(API18)未満では定義は有るものの決して呼び出してはいけないメソッドなのです(クラッシュします)。じゃぁどうすんねんと言うと、今回のサンプルはAPI>=18なので省略していますが、BufferInfo#flagにMediaCodec.BUFFER_FLAG_CODEC_CONFIGがセットされた時に、自前でOutputFormatを準備してMediaMuxerへ引き渡す必要があります。
  2. 一旦MediaMuxer#startを呼び出してしまうとMediaMuxer#addTrackを呼べなくなる
    audioまたはvideoのいずれか一方だけであれば、自分さえ準備が終わって#addTrackを呼び出せば直ぐに#startを呼び出すことが出来ます。でも、複数のトラック(audioとvideo)を書き込むには、両方で#addTrackを呼び出して初めて#startを呼び出すことが出来るのです。audioとvideoのどちらが先に準備完了しても大丈夫なように同期・同期待ちが必要ってことです。
    ちなみに、#startを呼び出した後に(別スレッドから)#addTrackを呼び出すと例外生成します。

他にも、presentationTimeUsの調整とか、audioだとMediaCodec#signalEndOfInputStreamが使えね0とか、何でわざわざHandlerにしてるかなど、色々ハマりどころが有りますけど、コメントを一生懸命一杯書いたので頑張って解読して下さい。三0四ヶ月程前には色々苦労したなぁ0( ´Д`)=3

と言う事で今回はおしまいです。

お疲れ様でした。

このサンプルプロジェクトはGitHubで公開しています。Apache2 licenseなので自由に使って下さい。

リンクはこちら:GitHub:AudioVideoRecordingSample

音&動画の同時キャプチャがしたい(その2)

MediaCodec+MediaMuxerを使って音&動画の同時キャプチャがした0いの第2弾♪

前回は前置きが長かったせいか、抽象クラスのMediaEncoderだけで終わってしまったので、今回は頑張らねば。と言う事でいきなりコード(^^)v 簡単な音声の方から行きます。

MediaAudioEncoder

package com.serenegiant.encoder;
public class MediaAudioEncoder extends MediaEncoder {
	private static final boolean DEBUG = true;	// TODO set false on release
	private static final String TAG = "MediaAudioEncoder";

	private static final String MIME_TYPE = "audio/mp4a-latm";
    private static final int SAMPLE_RATE = 44100;	// 44.1[KHz] is only setting guaranteed to be available on all devices.
    private static final int BIT_RATE = 64000;
    
    private AudioThread mAudioThread = null;

	public MediaAudioEncoder(MediaMuxerWrapper muxer, MediaEncoderListener listener) {
		super(muxer, listener);
	}

	@Override
	protected void prepare() throws IOException {
		if (DEBUG) Log.v(TAG, "prepare:");
        mTrackIndex = -1;
        mMuxerStarted = mIsEOS = false;
        // prepare MediaCodec for AAC encoding of audio data from inernal mic.
        final MediaCodecInfo audioCodecInfo = selectAudioCodec(MIME_TYPE);
        if (audioCodecInfo == null) {
            Log.e(TAG, "Unable to find an appropriate codec for " + MIME_TYPE);
            return;
        }
		if (DEBUG) Log.i(TAG, "selected codec: " + audioCodecInfo.getName());

        final MediaFormat audioFormat = MediaFormat.createAudioFormat(MIME_TYPE, SAMPLE_RATE, 1);
		audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
		audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, AudioFormat.CHANNEL_IN_MONO);
		audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
		audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1);
//		audioFormat.setLong(MediaFormat.KEY_MAX_INPUT_SIZE, inputFile.length());
//      audioFormat.setLong(MediaFormat.KEY_DURATION, (long)durationInMs );
		if (DEBUG) Log.i(TAG, "format: " + audioFormat);
        mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
        mMediaCodec.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        mMediaCodec.start();
        if (DEBUG) Log.i(TAG, "prepare finishing");
        if (mListener != null) {
        	try {
        		mListener.onPrepared(this);
        	} catch (Exception e) {
        		Log.e(TAG, "prepare:", e);
        	}
        }
	}

    @Override
	protected void startRecording() {
		super.startRecording();
		// create and execute audio capturing thread using internal mic
		if (mAudioThread == null) {
	        mAudioThread = new AudioThread();
			mAudioThread.start();
		}
	}

	@Override
    protected void release() {
		mAudioThread = null;
		super.release();
    }

	/**
	 * Thread to capture audio data from internal mic as uncompressed 16bit PCM data
	 * and write them to the MediaCodec encoder
	 */
    private class AudioThread extends Thread {
    	@Override
    	public void run() {
            final int buf_sz = AudioRecord.getMinBufferSize(
            	SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) * 4;
            final AudioRecord audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC,
            	SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, buf_sz);
            try {
            	if (mIsCapturing) {
    				if (DEBUG) Log.v(TAG, "AudioThread:start audio recording");
	                final byte[] buf = new byte[buf_sz];
	                int readBytes;
	                audioRecord.startRecording();
	                try {
			    		while (mIsCapturing && !mRequestStop && !mIsEOS) {
			    			// read audio data from internal mic
			    			readBytes = audioRecord.read(buf, 0, buf_sz);
			    			if (readBytes > 0) {
			    			    // set audio data to encoder
			    				encode(buf, readBytes, getPTSUs());
			    				frameAvailableSoon();
			    			}
			    		}
	    				frameAvailableSoon();
	                } finally {
	                	audioRecord.stop();
	                }
            	}
            } finally {
            	audioRecord.release();
            }
			if (DEBUG) Log.v(TAG, "AudioThread:finished");
    	}
    }

    /**
     * select the first codec that match a specific MIME type
     * @param mimeType
     * @return
     */
    private static final MediaCodecInfo selectAudioCodec(String mimeType) {
    	if (DEBUG) Log.v(TAG, "selectAudioCodec:");

    	MediaCodecInfo result = null;
    	// get the list of available codecs
        final int numCodecs = MediaCodecList.getCodecCount();
LOOP:	for (int i = 0; i < numCodecs; i++) {
        	final MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);
            if (!codecInfo.isEncoder()) {	// skipp decoder
                continue;
            }
            final String[] types = codecInfo.getSupportedTypes();
            for (int j = 0; j < types.length; j++) {
            	if (DEBUG) Log.i(TAG, "supportedType:" + codecInfo.getName() + ",MIME=" + types[j]);
                if (types[j].equalsIgnoreCase(mimeType)) {
                	if (result == null) {
                		result = codecInfo;
               			break LOOP;
                	}
                }
            }
        }
   		return result;
    }
}

抽象クラスのMediaEncoderを継承しています(2行目)。実装するのは、#prepare, #startRecording, #release, とsourceスレッドであるAudioThreadと、#prepareのヘルプ用の#selectAudioCodecです。簡単な方から説明します。

#startRecording

#startRecordingでは親の#startRecordingを呼び出した後、sourceスレッド(AudioThread)の生成と実行開始をしていますが、#prepareの最後に移動することも出来ます。ただし、その場合にはAudioThread内でstartRecording待ちの処理を追加しないといけません。

わざわざ#startRecordingというメソッドを作ったのに、AudioThread内にも待機処理を追加しないといけないのでは芸がありませんので、#startRecordingが呼ばれた時に初めてsourceスレッドを生成・実行するようにしています。

#release

これはまぁわざわざ説明するまでも無いですし、必須でもありませんがGCを確実にするためにnull代入しています。実際にはmAudioThreadフィールド自体をなくしてしまうことも可能ですが、個人的に気持ち悪いのでこんな事をしています。

#prepare

ここは、MediaCodec関係の実装で一番大事なものの1つです。これを間違えると最悪catch出来ない例外でクラッシュします。audioでは経験無いですが、videoだと端末丸ごとリセットっていうパターンもありです(´・ω・`)。と言ってもワンパターンなので大抵は丸ごとコピーですね(-_-;)

全ての機種でサポートされていることが保証されているサンプリングレートは44.1[KHz]のみらしいです。なので特に理由がない限りサンプリングレートには44100を指定しましょう。

端末内蔵マイクでステレオの物は見たことの聞いたこともないので、モノラル指定。ということはビットレートは64Kbpsもあれば十分でしょう。ステレオなら128Kbps相当ですね。ビットレートを上げれば基本的には音質が良くなっていきます。ただ端末の種類にもよりますが端末内蔵マイクからの入力自体がそんなに高音質ではないのでほどほどがいいかも。確か320Kbps程度まで設定出来たと思いますが、サポートしてない再生ソフトもありますし、高ビットレートにするとvideo側の帯域にも影響してしまいます。最新機種はともかく少し古くなるとNexus7(2012)等の様にaudio+video合計で1Mbps出ない機種も多くあります。

後は普通に、コーデックを選択・MediaFormatを設定・MediaCodec#createEncoderByTypeでコーデックを生成・configure・startの順に実行するだけです。

AudioThread

sourceスレッドの実体です。AudioRecordを使って16ビット無圧縮PCM音声データを取得してバイト配列として#encodeへ引き渡すだけです。後は上位クラスのMediaEncoderが勝手に処理してくれます。便利ですね0わざわざ抽象クラスを作ったかいが有るというものです。

ここで気を付けるのは、AudioRecord#getMinBufferSizeが返してきた値をそのまま使うのはあまり良くないということです。2倍以上を指定するのが定番でしょう。小さすぎると音声が途切れたりします。端末の性能が低かったり他の処理の負荷が重い場合は大きめにした方が良いのかもしれません。でも大きすぎるとメモリの無駄遣いでGCが多くなってかえって思ったように動かなくなります。今回は余裕を見て4倍に設定しています。

なお、ここでの処理は単純で直線的なので余計なことはせずに全て#run内に押し込めています。

#selectAudioCodec

ここも説明は必要ないでしょうけど。

別の記事MediaCodecInfo#getCapabilitiesForTypeが激遅になる件に書いたとおり、コーデックの選択方法は2通りありますが、たぶんこっちの方がいいと思います。

今日は家の近所も時々大雨でしたが、ソースの嵐もまだまだ続きます。次はvideoです。


MediaVideoEncoder

こっちはちょっと面倒くさいです。audio+videoだから面倒というわけでは無いですが。

ではコード行きま0す(^o^)/コード発進?発信(笑)

package com.serenegiant.encoder;
public class MediaVideoEncoder extends MediaEncoder {
	private static final boolean DEBUG = true;	// TODO set false on release
	private static final String TAG = "MediaVideoEncoder";

	private static final String MIME_TYPE = "video/avc";
	// parameters for recording
	// VIDEO_WITH and VIDEO_HEIGHT should be same as the camera preview size.
    private static final int VIDEO_WIDTH = 640;
    private static final int VIDEO_HEIGHT = 480;
    private static final int FRAME_RATE = 15;
    private static final float BPP = 0.125f;
 
    private RenderHandler mRenderHandler;
    private Surface mSurface;
	
	public MediaVideoEncoder(MediaMuxerWrapper muxer, MediaEncoderListener listener) {
		super(muxer, listener);
		if (DEBUG) Log.i(TAG, "MediaVideoEncoder: ");
		mRenderHandler = RenderHandler.createHandler(TAG);
	}

	public boolean frameAvailableSoon(final float[] tex_matrix) {
		boolean result;
		if (result = super.frameAvailableSoon())
			mRenderHandler.draw(tex_matrix);
		return result;
	}
	
	@Override
	protected void prepare() throws IOException {
		if (DEBUG) Log.i(TAG, "prepare: ");
        mTrackIndex = -1;
        mMuxerStarted = mIsEOS = false;

        final MediaCodecInfo videoCodecInfo = selectVideoCodec(MIME_TYPE);
        if (videoCodecInfo == null) {
            Log.e(TAG, "Unable to find an appropriate codec for " + MIME_TYPE);
            return;
        }
		if (DEBUG) Log.i(TAG, "selected codec: " + videoCodecInfo.getName());

        final MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, VIDEO_WIDTH, VIDEO_HEIGHT);
        format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);	// API >= 18
        format.setInteger(MediaFormat.KEY_BIT_RATE, calcBitRate());
        format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
        format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10);
		if (DEBUG) Log.i(TAG, "format: " + format);

        mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
        mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        // get Surface for encoder input
        // this method only can call between #configure and #start 
        mSurface = mMediaCodec.createInputSurface();	// API >= 18
        mMediaCodec.start();
        if (DEBUG) Log.i(TAG, "prepare finishing");
        if (mListener != null) {
        	try {
        		mListener.onPrepared(this);
        	} catch (Exception e) {
        		Log.e(TAG, "prepare:", e);
        	}
        }
	}

	public void setEglContext(EGLContext shared_context, int tex_id) {
		mRenderHandler.setEglContext(shared_context, tex_id, mSurface);
	}

	@Override
    protected void release() {
		if (DEBUG) Log.i(TAG, "release: ");
		if (mSurface != null) {
			mSurface.release();
			mSurface = null;
		}
		if (mRenderHandler == null) {
			mRenderHandler.release();
			mRenderHandler = null;
		}
		super.release();
	}

	private int calcBitRate() {
		final int bitrate = (int)(BPP * FRAME_RATE * VIDEO_WIDTH * VIDEO_HEIGHT);
		Log.i(TAG, String.format("bitrate=%5.2f[Mbps]", bitrate / 1024f / 1024f));
		return bitrate;
	}
	
    /**
     * select the first codec that match a specific MIME type
     * @param mimeType
     * @return null if no codec matched 
     */
    protected static final MediaCodecInfo selectVideoCodec(String mimeType) {
    	if (DEBUG) Log.v(TAG, "selectVideoCodec:");

    	// get the list of available codecs
        final int numCodecs = MediaCodecList.getCodecCount();
        for (int i = 0; i < numCodecs; i++) {
        	final MediaCodecInfo codecInfo = MediaCodecList.getCodecInfoAt(i);

            if (!codecInfo.isEncoder()) {	// skipp decoder
                continue;
            }
            // select first codec that match a specific MIME type and color format
            final String[] types = codecInfo.getSupportedTypes();
            for (int j = 0; j < types.length; j++) {
                if (types[j].equalsIgnoreCase(mimeType)) {
                	if (DEBUG) Log.i(TAG, "codec:" + codecInfo.getName() + ",MIME=" + types[j]);
            		int format = selectColorFormat(codecInfo, mimeType);
                	if (format > 0) {
                		return codecInfo;
                	}
                }
            }
        }
        return null;
    }

    /**
     * select color format available on specific codec and we can use.
     * @return 0 if no colorFormat is matched
     */
    protected static final int selectColorFormat(MediaCodecInfo codecInfo, String mimeType) {
		if (DEBUG) Log.i(TAG, "selectColorFormat: ");
    	int result = 0;
    	final MediaCodecInfo.CodecCapabilities caps;
    	try {
    		Thread.currentThread().setPriority(Thread.MAX_PRIORITY);
    		caps = codecInfo.getCapabilitiesForType(mimeType);
    	} finally {
    		Thread.currentThread().setPriority(Thread.NORM_PRIORITY);
    	}
        int colorFormat;
        for (int i = 0; i < caps.colorFormats.length; i++) {
        	colorFormat = caps.colorFormats[i];
            if (isRecognizedViewoFormat(colorFormat)) {
            	if (result == 0)
            		result = colorFormat;
                break;
            }
        }
        if (result == 0)
        	Log.e(TAG, "couldn't find a good color format for " + codecInfo.getName() + " / " + mimeType);
        return result;
    }

	/**
	 * color formats that we can use in this class
	 */
    protected static int[] recognizedFormats;
	static {
		recognizedFormats = new int[] {
//        	MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar,
//        	MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
//        	MediaCodecInfo.CodecCapabilities.COLOR_QCOM_FormatYUV420SemiPlanar,
        	MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface,
		};
	}

    private static final boolean isRecognizedViewoFormat(int colorFormat) {
		if (DEBUG) Log.i(TAG, "isRecognizedViewoFormat:colorFormat=" + colorFormat);
    	final int n = recognizedFormats != null ? recognizedFormats.length : 0;
    	for (int i = 0; i < n; i++) {
    		if (recognizedFormats[i] == colorFormat) {
    			return true;
    		}
    	}
    	return false;
    }
}

こっちももちろん抽象クラスのMediaEncoderを継承しています。

#selectVideoCodecとそれに関係するprivateメソッドは今までに何度もこのブログに登場しているので省略です。

ってことは、定番の#prepareと#release…これも何度も出てきているので省略(^_^;) いきなり登場のRenderHandlerも後で説明するはずなので省略です。

あれっ?ってことはせっかくソース掛けたけど食べるとこが無いってこと?(笑)

あんまりなので、#frameAvailableSoonを。#frameAvailableSoonはMediaCodec(正確にはMediaEncoder)にもうすぐデータが来るよ/来たよってことを通知するメソッドです。MediaEncoderではこれをトリガにしてMediaCodecからの出力データの処理を行います。さっきのAudioThreadでは#encodeを呼び出した後に呼んでましたよね。MediaVideoEncoderの場合は、オーバーライドしてここでMediaCodecから取得した入力用のSurfaceへの描画(要求)処理を行っています。

んじゃまぁ、MediaMuxerWrapperにでもいっちゃいますか。


その1に書いたとおり、MediaMuxerWrapperはMediaMuxerに機能を追加したクラスです。何でMediaMuxerを継承しないのかって?MediaMuxerはfinalクラスなので継承できないのです(´・ω・`)たぶんnativeメソッドが色々有るので中途半端に変な事をされるとうまく動かなくなるからじゃないかと想像しますけど。

と言う事でそーす(^^)v たまには味噌や醤油がいいけど♪(翻訳ソフトだと日本語よりももっと意味不明な訳になってごめんなさいm(_ _)m)

package com.serenegiant.encoder;

public class MediaMuxerWrapper {
	private static final boolean DEBUG = true;	// TODO set false on release
	private static final String TAG = "MediaMuxerWrapper";

	private static final String DIR_NAME = "AVRecSample";
    private static final SimpleDateFormat mDateTimeFormat = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US);
    
	private String mOutputPath;
	private MediaMuxer mMediaMuxer;	// API >= 18
	private int mEncoderCount, mStatredCount;
	private boolean mIsStarted;
	private MediaEncoder mVideoEncoder, mAudioEncoder;

	/**
	 * Constructor	
	 * @param ext extension of output file
	 * @throws IOException
	 */
	public MediaMuxerWrapper(String ext) throws IOException {
		if (TextUtils.isEmpty(ext)) ext = ".mp4";
		try {
			mOutputPath = getCaptureFile(Environment.DIRECTORY_MOVIES, ext).toString();
		} catch (NullPointerException e) {
			throw new RuntimeException("This app has no permission of writing external storage");
		}
		mMediaMuxer = new MediaMuxer(mOutputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
		mEncoderCount = mStatredCount = 0;
		mIsStarted = false;
	}

	public void prepare() throws IOException {
		if (mVideoEncoder != null)
			mVideoEncoder.prepare();
		if (mAudioEncoder != null)
			mAudioEncoder.prepare();
	}

	public void startRecording() {
		if (mVideoEncoder != null)
			mVideoEncoder.startRecording();
		if (mAudioEncoder != null)
			mAudioEncoder.startRecording();
	}

	public void stopRecording() {
		if (mVideoEncoder != null)
			mVideoEncoder.stopRecording();
		mVideoEncoder = null;
		if (mAudioEncoder != null)
			mAudioEncoder.stopRecording();
		mAudioEncoder = null;
	}

	public synchronized boolean isStarted() {
		return mIsStarted;
	}

//**********************************************************************
//**********************************************************************
	/**
	 * assign encoder to this calss. this is called from encoder.
	 * @param encoder instance of MediaVideoEncoder or MediaAudioEncoder
	 */
	/*package*/ void addEncoder(MediaEncoder encoder) {
		if (encoder instanceof MediaVideoEncoder) {
			if (mVideoEncoder != null)
				throw new IllegalArgumentException("Video encoder already added.");
			mVideoEncoder = encoder;
		} else if (encoder instanceof MediaAudioEncoder) {
			if (mAudioEncoder != null)
				throw new IllegalArgumentException("Video encoder already added.");
			mAudioEncoder = encoder;
		} else
			throw new IllegalArgumentException("unsupported encoder");
		mEncoderCount = (mVideoEncoder != null ? 1 : 0) + (mAudioEncoder != null ? 1 : 0);
	}

	/**
	 * request start recording from encoder
	 * @return true when muxer is ready to write
	 */
	/*package*/ synchronized boolean start() {
		if (DEBUG) Log.v(TAG,  "start:");
		mStatredCount++;
		if ((mEncoderCount > 0) && (mStatredCount == mEncoderCount)) {
			mMediaMuxer.start();
			mIsStarted = true;
			notifyAll();
			if (DEBUG) Log.v(TAG,  "MediaMuxer started:");
		}
		return mIsStarted;
	}
	
	/**
	 * request stop recording from encoder when encoder received EOS 
	*/
	/*package*/ synchronized void stop() {
		if (DEBUG) Log.v(TAG,  "stop:mStatredCount=" + mStatredCount);
		mStatredCount--;
		if ((mEncoderCount > 0) && (mStatredCount <= 0)) {
			mMediaMuxer.stop();
			mIsStarted = false;
			if (DEBUG) Log.v(TAG,  "MediaMuxer stopped:");
		}
	}

	/**
	 * assign encoder to muxer
	 * @param format
	 * @return minus value indicate error
	 */
	/*package*/ synchronized int addTrack(MediaFormat format) {
		if (mIsStarted)
			throw new IllegalStateException("muxer already started");
		final int trackIx = mMediaMuxer.addTrack(format);
		if (DEBUG) Log.i(TAG, "addTrack:trackNum=" + mEncoderCount + ",trackIx=" + trackIx + ",format=" + format); 
		return trackIx;
	}

	/**
	 * write encoded data to muxer
	 * @param trackIndex
	 * @param byteBuf
	 * @param bufferInfo
	 */
	/*package*/ synchronized void writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo) {
		if (mStatredCount > 0)
			mMediaMuxer.writeSampleData(trackIndex, byteBuf, bufferInfo);
	}

//**********************************************************************
//**********************************************************************
    /**
     * generate output file
     * @param type Environment.DIRECTORY_MOVIES / Environment.DIRECTORY_DCIM etc.
     * @param ext .mp4(.m4a for audio) or .png
     * @return return null when this app has no writing permission to external storage.
     */
    private static final File getCaptureFile(String type, String ext) {
		final File dir = new File(Environment.getExternalStoragePublicDirectory(type), DIR_NAME);
		Log.d(TAG, "path=" + dir.toString());
		dir.mkdirs();
        if (dir.canWrite()) {
        	return new File(dir, getDateTimeString() + ext);
        }
    	return null;
    }

    /**
     * get current date and time as String
     * @return
     */
    private static final String getDateTimeString() {
    	final GregorianCalendar now = new GregorianCalendar();
    	return mDateTimeFormat.format(now.getTime());
    }
}

と言っても、その1で書いたように単に見通しを良くするためだけにMediaEncoderから分離させたので、これもあんまり大したことしてないです。MediaEncoderと一心同体でしか動きません。蜜結合状態もとい密結合状態です。<=これっと意味のある翻訳はできないだろうなぁ(^_^;) 使い方としては、MediaMuxerWarpperを生成 > MediaAudioEncoder and/or MediaVideoEncoderを生成 > #prepareを呼ぶ > #startRecordingを呼ぶ > … > #stopRecordingを呼ぶ > null代入 って感じですね。元になっているMediaMuxerの制限により、1つのMediaMuxerWrapperに割り当てることが出来るMediaAudioEncoderとMediaVideoEncoderはそれぞれ最低1個ずつ、最大1個ずつです。同じ種類のMediaEncoderインスタンスを割り当てようとするとIllegalArgumentException例外を生成します。プログラムミスってことだからね。

処理としては、MediaEncoderのコンストラクタ内でMediaMuxerWarpper#addEncoderを呼び出して、生成したMediaEncoderの数を数えています。後はMediaEncoderから#start、#stopを呼び出した時に自分の知っているMediaEncoderの数と比較して実際のMediaMuxer#start, MediaMuxer#stopを呼び出すかどうかを判断しています。

今回のサンプルでは、その方がわかりやすいかと思ってMediaEncoder#drain内でstart待ち処理を行っていますが、MediaMuxerWarpper#start内にstart待ち処理を実装しても大丈夫です。プログラム上はその方が見通しが良いかも。

MediaCodec/MedicMuxerを使って同時録音録画の実装のコア部分はこれでおしまいです。難しいとこあった?質問は随時受け付けます。

残るのは内蔵カメラからのプレビュー表示兼録画に関係したトピックのみです。同時録音録画については、サンプル・資料が少ないでの大変、プレビュー表示兼録画は実装のボニュームが多くて大変って感じかな。次回は内蔵カメラからのプレビュー表示兼録画についてです。と言ってもこっちは既に世の中に色々サンプルあるからあまり役に立たないかも。

と言う事で今回はおしまい。

お疲れ様でした。

このサンプルプはGitHubで公開しています。Apache2 licenseなので自由に使って下さい。

リンクはこちら:GitHub:AudioVideoRecordingSample

音&動画の同時キャプチャがしたい(その3)

MediaCodec+MediaMuxerを使って音&動画の同時キャプチャがした0いの第3弾♪

もっとも前回の最後で書いたとおりメインの部分は既に出てきてしまっているので、付録です。ボリューム的にはこっちがメインだけど。

やっぱり前置き

何で付録が必要かと言うと、カメラからの映像取得とプレビュー画面の表示部分に原因があります。

というのも、御存知の通り通常は、内蔵カメラからのプレビュー映像取得表示は、

Camera#setPreviewTexture(SurfaceTexture surfaceTexture) または

Camera#setPreviewDisplay(SurfaceHolder holder)

を使います。SurfaceTextureまたはSurfaceHolderを渡すことで簡単にTextureViewやSurfaceViewにプレビュー画面を表示できる優れものです。

でも一度にセットできるSurfaceTextureまたはSurfaceHolderは1つだけです。MediaCodecへ入力するフレームデータはどうやって取得すればいいのでしょう?

オーソドックスな方法・・・でも非効率的

例えば次のような方法が考えられます。

  1. #setPreviewCallback(Camera.PreviewCallback cb) や
    #setPreviewCallbackWithBuffer(Camera.PreviewCallback cb)
    を使ってCamera#PreviewCallbackを割り当てて、
    #onPreviewFrame(byte[] data, Camera camera)
    コールバックメソッドでバイト配列としてフレームデータを取得する方法。 これの欠点は、動作が遅い・得られたフレームデータからピクセルフォーマットの変換・端末の向きに合わせて映像の回転処理を自前でしないといけない事などが挙げられます。元々GPU内にコピーされているフレームデータを一旦Javaのバイト配列として取り出して変換した後再度GPUへ送ることになるので、フレームレートが低くなる・消費電力が多く電池の消耗が激しいってことになります。
  2. あるいは、TextureViewから取得したSurfaceTextureを#setPreviewTextureでカメラへ引き渡して、#onFrameAvailableイベントリスナーが呼び出された時に、TextureView#getBitmapメソッドでビットマップとしてフレームデータを取得し、MediaCodecのSurfaceへ描画することも出来ます。 この方法だとピクセルフォーマットや映像の回転はしないで済みますが、御存知の通りAndroidのBitmapは遅いし処理の負荷がかなり高いので、やはり効率がよくありません。 ちなみに、TextureView#getBitmapには
    getBitmap(int width, int height)
    getBitmap()
    getBitmap(Bitmap bitmap)
    

    の3種類のバリエーションが有りますが、仮にこの方法でMediaCodecの入力用データを取得する場合には最後のものしか使ってはいけませんし、最後のものを使う場合にも注意深くコーディングする必要があります。試してみれば直ぐに判りますが、上2つでは簡単にOOMでクラッシュします。上2つが使えるのは静止画のキャプチャの用に単発・低頻度の場合のみです。
  3. GLSurfaceViewのSurfaceをカメラへ渡して描画されたデータを、glReadPixelsで読み取って・・・

他にも類似の方法は有るかもしれませんが、共通しているのはカメラがGPUに書き込んだフレームデータを一旦JavaもしくはNativeコード側へ取り出さないといけない点で、これが効率&速度低下の一番の原因になっています。

んじゃどうすんねん

って言うと、要はGPUからデータを取り出さなきゃいいわけです。というよりTextureView/SurfaceTextureが作られた理由の1つがこのためではないでしょうか(他にも重要な目的が有りますが)。

SurfaceTextureのコンストラクタにはOpenGL|ESのテクスチャID(テクスチャハンドル)を引き渡すことが出来ますので、このテクスチャIDとOpenGL|ESを使ってプレビュー画面用とMediaCodec用に描画すれば良いのです。

でも2回描画するのって遅くならないか心配に思います?同じスレッド内で2回描画すると確かに2倍以上の時間がかかります。そりゃそうだ。でも、違う2種類の場所(Surfaceまたはwindow)にフレームデータを転送(描画)しないといけないので最低2回の描画は避けて通れません。あとはいかに効率良く行うかだけです。

どうしたら良いと思いますか?答えの1つがマルチスレッドです。今どきの端末はマルチコアCPUにマルチコアGPUってことを知ってますか?そう、マルチスレッドで描画すれば最良の場合、2回描画しても1フレーム当たりの描画時間は1回分+αで済みます。(シングルCPU/シングルGPUの場合など)最悪の場合でも2回分の描画時間になるだけです。

そう言う事でEGL

いきなり何のこっちゃ。

OpenGL|ES, EGLについてはあまり専門家ではないので以下はあくまでも自分の理解に過ぎません。間違ってたら教えて下さいな。

とりあえずマルチスレッドでのOpenGL|ESの描画について調べてみた
  1. OpenGL(ES)のコマンドを呼び出すと、そのスレッドに紐付けられているEGLContextに対して操作が行われる
  2. OpenGL|ESの制限として、1つのスレッドに対して1つのEGLContextしか紐付けることができない
  3. 1つのEGLContextは1つのスレッドに対してしか紐付けることが出来ない

つまり、

  1. どこかのスレッドで使われているEGLContextを他のスレッドで利用することは出来ない
  2. マルチスレッドでOpenGL|ESで描画するには、各スレッド毎にEGLContextを作らないといけない

ってことらしいです。分かったようなわからんような(-_-;)そもそもEGLContextってのがわかってませんからね。

EGLContextってなんぞや

OpenGL(ES)の画面クリア色やテクスチャその他OpenGL(ES)の描画にまつわる様々な状態・リソースを保持するオブジェクト?らしいです。また、あるEGLContextに対して行った操作は他のEGLContextに影響を及ぼさないそうです。これも当然ですね。でないと画面の何処かをOpenGL|ESで赤く塗りつぶしたら他の部分も赤くなっちゃいますからね。

でも、「あるEGLContextに対して行った操作は他のEGLContextに影響を及ぼさない」ってことは、このままではテクスチャも各EGLContext毎に読み込まないと駄目になります。それだと今したいことには役に立ちません。

そこで登場するのがshared_contextらしいです。shared_contextとやらを使うと、OpenGL(ES)の状態は別個に持ったままテクスチャ等のリソースだけは共有できるようになるそうです。つまり、カメラが(テクスチャに)書き込んでくれたフレームデータをプレビュー描画用とMediaCodecへの入力用の2つのスレッドから(実際にはメモリ等のハードウエアが許す限りのスレッドから)共有して使用することが出来るってことです。何が共有できて何が出来ないかは自分で調べましょうm(__)m

でやっとEGL

今回も前置きが長くなっちゃったから、EGLContextを作ったり破棄したりをするための関数定義がEGL、と言う事にしておこう(-_-;)やっと本題です。


ここからが本題

とりあえず、EGLContext関係の処理をするクラスを自分なりに作ってみたのがEGLBaseクラス。

EGLBaseを使ってプライベートスレッド用のEGLContextを生成・プライベートスレッド内で描画するためのヘルパーがRenderHandlerクラス。更にテクスチャをView全面に表示するためのGLDrawer2Dクラスを作りました。

どれも特に目新しいところは無いけど、こんな感じです。

EGLBase
package com.serenegiant.glutils;
public class EGLBase {
	private static final boolean DEBUG = false;	// TODO set false on release
	private static final String TAG = "EGLBase";

    private static final int EGL_RECORDABLE_ANDROID = 0x3142;

    private EGLConfig mEglConfig = null;
	private EGLContext mEglContext = EGL14.EGL_NO_CONTEXT;
	private EGLDisplay mEglDisplay = EGL14.EGL_NO_DISPLAY;

	public static class EglSurface {
		private final EGLBase mEgl;
		private EGLSurface mEglSurface = EGL14.EGL_NO_SURFACE;

		EglSurface(EGLBase egl, Surface surface) {
			if (DEBUG) Log.i(TAG, "EglSurface:");
			mEgl = egl;
			mEglSurface = mEgl.createWindowSurface(surface);
		}

		public void makeCurrent() {
			mEgl.makeCurrent(mEglSurface);
		}

		public void swap() {
			mEgl.swap(mEglSurface);
		}

		public void release() {
			if (DEBUG) Log.i(TAG, "EglSurface:release:");
			mEgl.destroyWindowSurface(mEglSurface);
	        mEglSurface = EGL14.EGL_NO_SURFACE;
		}
	}

	public EGLBase(EGLContext shared_context, boolean with_depth_buffer) {
		if (DEBUG) Log.i(TAG, "EGLBase:");
		init(shared_context, with_depth_buffer);
	}

    public void release() {
		if (DEBUG) Log.v(TAG, "release:");
        if (mEglDisplay != EGL14.EGL_NO_DISPLAY) {
	    	destroyContext();
	        EGL14.eglTerminate(mEglDisplay);
	        EGL14.eglReleaseThread();
        }
        mEglDisplay = EGL14.EGL_NO_DISPLAY;
        mEglContext = EGL14.EGL_NO_CONTEXT;
    }

	public EglSurface createFromSurface(Surface surface) {
		if (DEBUG) Log.i(TAG, "createFromSurface:");
		final EglSurface eglSurface = new EglSurface(this, surface);
		return eglSurface;
	}

	private void init(EGLContext shared_context, boolean with_depth_buffer) {
		if (DEBUG) Log.v(TAG, "init:");
        if (mEglDisplay != EGL14.EGL_NO_DISPLAY) {
            throw new RuntimeException("EGL already set up");
        }

        mEglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
        if (mEglDisplay == EGL14.EGL_NO_DISPLAY) {
            throw new RuntimeException("eglGetDisplay failed");
        }

		final int[] version = new int[2];
        if (!EGL14.eglInitialize(mEglDisplay, version, 0, version, 1)) {
        	mEglDisplay = null;
            throw new RuntimeException("eglInitialize failed");
        }

		shared_context = shared_context != null ? shared_context : EGL14.EGL_NO_CONTEXT;
        if (mEglContext == EGL14.EGL_NO_CONTEXT) {
            mEglConfig = getConfig(with_depth_buffer);
            if (mEglConfig == null) {
                throw new RuntimeException("chooseConfig failed");
            }
            // create EGL rendering context
	        mEglContext = createContext(shared_context);
        }
        // confirm whether the EGL rendering context is successfully created
        final int[] values = new int[1];
        EGL14.eglQueryContext(mEglDisplay, mEglContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, values, 0);
        if (DEBUG) Log.d(TAG, "EGLContext created, client version " + values[0]);
	}

	/**
	 * change context to draw this window surface
	 * @return
	 */
	private boolean makeCurrent(EGLSurface surface) {
		if (DEBUG) Log.v(TAG, "makeCurrent:");
        if (mEglDisplay == null) {
            if (DEBUG) Log.d(TAG, "makeCurrent:eglDisplay not initialized");
        }
        if (surface == null || surface == EGL14.EGL_NO_SURFACE) {
            int error = EGL14.eglGetError();
            if (error == EGL14.EGL_BAD_NATIVE_WINDOW) {
                Log.e(TAG, "makeCurrent:returned EGL_BAD_NATIVE_WINDOW.");
            }
            return false;
        }
        // attach EGL renderring context to specific EGL window surface
        if (!EGL14.eglMakeCurrent(mEglDisplay, surface, surface, mEglContext)) {
            Log.w("TAG", "eglMakeCurrent" + EGL14.eglGetError());
            return false;
        }
        return true;
	}

	private int swap(EGLSurface surface) {
		if (DEBUG) Log.v(TAG, "swap:");
        if (!EGL14.eglSwapBuffers(mEglDisplay, surface)) {
        	final int err = EGL14.eglGetError();
        	if (DEBUG) Log.w(TAG, "swap:err=" + err);
            return err;
        }
        return EGL14.EGL_SUCCESS;
    }

    private EGLContext createContext(EGLContext shared_context) {
		if (DEBUG) Log.v(TAG, "createContext:");

        final int[] attrib_list = {
        	EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
        	EGL14.EGL_NONE
        };
        final EGLContext context = EGL14.eglCreateContext(mEglDisplay, mEglConfig, shared_context, attrib_list, 0);
        checkEglError("eglCreateContext");
        return context;
    }

    private void destroyContext() {
		if (DEBUG) Log.v(TAG, "destroyContext:");

        if (!EGL14.eglDestroyContext(mEglDisplay, mEglContext)) {
            Log.e("DefaultContextFactory", "display:" + mEglDisplay + " context: " + mEglContext);
            Log.e(TAG, "eglDestroyContex:" + EGL14.eglGetError());
        }
        mEglContext = EGL14.EGL_NO_CONTEXT;
    }

    private EGLSurface createWindowSurface(Object nativeWindow) {
		if (DEBUG) Log.v(TAG, "createWindowSurface:");

        final int[] surfaceAttribs = {
                EGL14.EGL_NONE
        };
		EGLSurface result = null;
		try {
			result = EGL14.eglCreateWindowSurface(mEglDisplay, mEglConfig, nativeWindow, surfaceAttribs, 0);
		} catch (IllegalArgumentException e) {
			Log.e(TAG, "eglCreateWindowSurface", e);
		}
		return result;
	}

	private void destroyWindowSurface(EGLSurface surface) {
		if (DEBUG) Log.v(TAG, "destroySurface:");

        if (surface != EGL14.EGL_NO_SURFACE) {
        	EGL14.eglMakeCurrent(mEglDisplay,
        		EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT);
        	EGL14.eglDestroySurface(mEglDisplay, surface);
        }
        surface = EGL14.EGL_NO_SURFACE;
	}
	
    private void checkEglError(String msg) {
        int error;
        if ((error = EGL14.eglGetError()) != EGL14.EGL_SUCCESS) {
            throw new RuntimeException(msg + ": EGL error: 0x" + Integer.toHexString(error));
        }
    }

    private EGLConfig getConfig(boolean with_depth_buffer) {
        final int[] attribList = {
                EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
                EGL14.EGL_RED_SIZE, 8,
                EGL14.EGL_GREEN_SIZE, 8,
                EGL14.EGL_BLUE_SIZE, 8,
                EGL14.EGL_ALPHA_SIZE, 8,
                //EGL14.EGL_STENCIL_SIZE, 8,
                EGL_RECORDABLE_ANDROID, 1,	// this flag need to recording of MediaCodec
				with_depth_buffer ? EGL14.EGL_DEPTH_SIZE : EGL14.EGL_NONE,
				with_depth_buffer ? 16 : 0,
                EGL14.EGL_NONE
        };
        final EGLConfig[] configs = new EGLConfig[1];
        final int[] numConfigs = new int[1];
        if (!EGL14.eglChooseConfig(mEglDisplay, attribList, 0, configs, 0, configs.length, numConfigs, 0)) {
        	// XXX it will be better to fallback to RGB565
            Log.w(TAG, "unable to find RGBA8888 / " + " EGLConfig");
            return null;
        }
        return configs[0];
    }
}

今回のサンプルはAPI>=18なので、EGL14(API>=17で使用可)を使っています。JavaのSurfaceインスタンスから描画用のEglSurfaceとやらを生成するためのヘルパーメソッドも作ってあります。

ちなみに、API<17に対応させる場合には、GLSurfaceView.EGLConfigChooser, GLSurfaceView.EGLContextFactoryや、GLSurfaceView.EGLContextFactoryを使う必要があります。

APIのレファレンスには記述がありませんが、MediaCodecの入力用のSurfaceで使用するには、188行目のEGL_RECORDABLE_ANDROIDが必須です。Grafikaから貰ってきました。これを忘れると描画時にはエラーは無いのにMediaCodecへ一切入力が無いという悲しいことになります。

あっそうそう、上のソースコードではimport分は省略していますが、importするのは android.opengl.*の方のEGLDisplayやEGLContext, EGLSurfaceです。同名のクラスが、javax.microedition.khronos.egl.*にも有りますが、今回のはそっちではありませんので注意して下さい。

んでもってEGLBaseを使った描画用のスレッドとハンドラーも作ってみました。個別のクラスに分ける必要は特になかったんやけどね。


RenderHandler

EGLBaseを使った描画用をプライベートスレッドで行うためのスレッドとハンドラーです。スレッド自体は内部に遮蔽してあるのでスレッドをあまり意識せずに使うことが出来ます。

package com.serenegiant.glutils;
/**
 * Helper class to draw texture to whole view on private thread
 */
public final class RenderHandler extends Handler {
	private static final boolean DEBUG = false;	// TODO set false on release
	private static final String TAG = "RenderHandler";

	private static final int MSG_RENDER_SET_GLCONTEXT = 1;
	private static final int MSG_RENDER_DRAW = 2;
	private static final int MSG_RENDER_QUIT = 9;

	private int mTexId = -1;
	private final RenderThread mThread;

	public static RenderHandler createHandler() {
		return createHandler(null);
	}

	public static final RenderHandler createHandler(String name) {
		final RenderThread thread = new RenderThread(name);
		thread.start();
		return thread.getHandler();
	}

	public final void setEglContext(EGLContext shared_context, int tex_id, Surface surface) {
		if (DEBUG) Log.i(TAG, "RenderHandler:setEglContext:");
		mTexId = tex_id;
		sendMessage(obtainMessage(MSG_RENDER_SET_GLCONTEXT, new ContextParams(shared_context, surface)));
	}

	public final void draw() {
		sendMessage(obtainMessage(MSG_RENDER_DRAW, mTexId, 0, null));
	}

	public final void draw(int tex_id) {
		sendMessage(obtainMessage(MSG_RENDER_DRAW, tex_id, 0, null));
	}

	public final void draw(final float[] tex_matrix) {
		sendMessage(obtainMessage(MSG_RENDER_DRAW, mTexId, 0, tex_matrix));
	}
	
	public final void draw(int tex_id, final float[] tex_matrix) {
		sendMessage(obtainMessage(MSG_RENDER_DRAW, tex_id, 0, tex_matrix));
	}

	public final void release() {
		if (DEBUG) Log.i(TAG, "release:");
		sendEmptyMessage(MSG_RENDER_QUIT);
	}

	@Override
	public final void handleMessage(Message msg) {
		switch (msg.what) {
		case MSG_RENDER_SET_GLCONTEXT:
			final ContextParams params = (ContextParams)msg.obj;
			mThread.setEglContext(params.shared_context, params.surface);
			break;
		case MSG_RENDER_DRAW:
			mThread.draw(msg.arg1, (float[])msg.obj);
			break;
		case MSG_RENDER_QUIT:
			Looper.myLooper().quit();
			break;
		default;
			super.handleMessage(msg);
		}
	}

//********************************************************************************
//********************************************************************************
	private RenderHandler(RenderThread thread) {
		if (DEBUG) Log.i(TAG, "RenderHandler:");
		mThread = thread;
	}

	private static final class ContextParams {
		final EGLContext shared_context;
		final Surface surface;
		public ContextParams(EGLContext shared_context, Surface surface) {
			this.shared_context = shared_context;
			this.surface = surface;
		}
	}

	/**
	 * Thread to execute render methods
	 * You can also use HandlerThread insted of this and create Handler from its Looper.  
	 */
	private static final class RenderThread extends Thread {
		private final Object mReadyFence = new Object();
		private RenderHandler mHandler;
		private EGLBase mEgl;
		private EGLBase.EglSurface mInputSurface;
		private GLDrawer2D mDrawer;

		public RenderThread(String name) {
			super(name);
		}

		public final RenderHandler getHandler() {
			synchronized (mReadyFence) {
				// create rendering thread
				try {
					mReadyFence.wait();
				} catch (InterruptedException e) {
				}
			}
			return mHandler;
		}

		@Override
		public final void run() {
			Log.d(TAG, getName() + " started");
			Looper.prepare();
			synchronized (mReadyFence) {
				mHandler = new RenderHandler(this);
				mReadyFence.notify();
			}
			Looper.loop();

			Log.d(TAG, getName() + " finishing");
			release();
			synchronized (mReadyFence) {
				mHandler = null;
			}
		}

		private final void release() {
			if (mInputSurface != null) {
				mInputSurface.release();
				mInputSurface = null;
			}
			if (mDrawer != null) {
				mDrawer.release();
				mDrawer = null;
			}
			if (mEgl != null) {
				mEgl.release();
				mEgl = null;
			}
		}

		private final void setEglContext(EGLContext shard_context, Surface surface) {
			if (DEBUG) Log.i(TAG, "RenderThread:setEglContext:");
			release();
			mEgl = new EGLBase(shard_context, false);
			mInputSurface = mEgl.createFromSurface(surface);
			mInputSurface.makeCurrent();
			mDrawer = new GLDrawer2D();
		}
 
		private void draw(int tex_id, final float[] tex_matrix) {
			if (DEBUG) Log.i(TAG, "RenderThread:draw");
			if (tex_id >= 0) {
				mInputSurface.makeCurrent();
				mDrawer.draw(tex_id, tex_matrix);
				mInputSurface.swap();
			}
		}
	}
}

使い方は、次のようにします。

  1. 予めRenderHandlerインスタンスを生成
  2. GLSurfaceViewのデフォルトのEGLContextを取得
  3. RenderHandler#setEglContextに引き渡して初期化
  4. 必要な時にRenderHandler#drawを呼び出す

なお、GLSurfaceViewのデフォルトのEGLContextを取得する際には、GLSurfaceViewのデフォルトのEGLContext内・・・つまりonSurfaceCreated0Surfaceが破棄されるまでの間のGLスレッド内でEGL14#eglGetCurrentContextを呼び出す必要があります。サンプルアプリでは、GLSurfaceView#queueEventを使ってGLスレッドに処理を依頼しています。

RenderHandler#drawでは描画用のプライベートスレッド(RenderThread)へ描画要求のメッセージを送るだけで直ぐに戻り、実際の描画はプライベートスレッド内で行います。このため呼び出し側のオーバーヘッドが少なく処理できます。

ちなみに、前回の記事のMediaVideoEncoder#setEglContextとか、MediaVideoEncoder#frameAvailableSoonで呼び出していたのが、今回のRenderHandlerになります。

今回のサンプルでの実際の描画処理は、定形でテクスチャをView全面に表示するだけなのと、プレビュー描画とMediaCodecの入力用の2箇所から使うことになるので、次に載せる専用のGLDrawer2Dって言う別クラスを作ってそっちで行っています。

もし、汎用的な描画を行うのであれば、GLSurfaceView.Rendererのようなコールバックインターフェース作って、RenderHandler内から必要に応じてコールバックメソッドを呼び出すようにすれば良いと思います。


GLDrawer2D

今回はOpenGL|ES2.0で作ってあります。ES1でも作れます。

package com.serenegiant.glutils;
/**
 * Helper class to draw to whole view using specific texture and texture matrix
 */
public class GLDrawer2D {
	private static final boolean DEBUG = false; // TODO set false on release
	private static final String TAG = "GLDrawer2D";

	private static final String vss
		= "uniform mat4 uMVPMatrix;\n"
		+ "uniform mat4 uTexMatrix;\n"
		+ "attribute highp vec4 aPosition;\n"
		+ "attribute highp vec4 aTextureCoord;\n"
		+ "varying highp vec2 vTextureCoord;\n"
		+ "\n"
		+ "void main() {\n"
		+ "	gl_Position = uMVPMatrix * aPosition;\n"
		+ "	vTextureCoord = (uTexMatrix * aTextureCoord).xy;\n"
		+ "}\n";
	private static final String fss
		= "#extension GL_OES_EGL_image_external : require\n"
		+ "precision mediump float;\n"
		+ "uniform samplerExternalOES sTexture;\n"
		+ "varying highp vec2 vTextureCoord;\n"
		+ "void main() {\n"
		+ "  gl_FragColor = texture2D(sTexture, vTextureCoord);\n"
		+ "}";
	private static final float[] VERTICES = { 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, -1.0f };
	private static final float[] TEXCOORD = { 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f };

	private final FloatBuffer pVertex;
	private final FloatBuffer pTexCoord;
	private int hProgram;
    int maPositionLoc;
    int maTextureCoordLoc;
    int muMVPMatrixLoc;
    int muTexMatrixLoc;
	private final float[] mMvpMatrix = new float[16];

	private static final int FLOAT_SZ = Float.SIZE / 8;
	private static final int VERTEX_NUM = 4;
	private static final int VERTEX_SZ = VERTEX_NUM * 2;
	/**
	 * Constructor
	 * this should be called in GL context
	 */
	public GLDrawer2D() {
		pVertex = ByteBuffer.allocateDirect(VERTEX_SZ * FLOAT_SZ)
				.order(ByteOrder.nativeOrder()).asFloatBuffer();
		pVertex.put(VERTICES);
		pVertex.flip();
		pTexCoord = ByteBuffer.allocateDirect(VERTEX_SZ * FLOAT_SZ)
				.order(ByteOrder.nativeOrder()).asFloatBuffer();
		pTexCoord.put(TEXCOORD);
		pTexCoord.flip();

		hProgram = loadShader(vss, fss);
		GLES20.glUseProgram(hProgram);
        maPositionLoc = GLES20.glGetAttribLocation(hProgram, "aPosition");
        maTextureCoordLoc = GLES20.glGetAttribLocation(hProgram, "aTextureCoord");
        muMVPMatrixLoc = GLES20.glGetUniformLocation(hProgram, "uMVPMatrix");
        muTexMatrixLoc = GLES20.glGetUniformLocation(hProgram, "uTexMatrix");

		Matrix.setIdentityM(mMvpMatrix, 0);
        GLES20.glUniformMatrix4fv(muMVPMatrixLoc, 1, false, mMvpMatrix, 0);
        GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, mMvpMatrix, 0);
		GLES20.glVertexAttribPointer(maPositionLoc, 2, GLES20.GL_FLOAT, false, VERTEX_SZ, pVertex);
		GLES20.glVertexAttribPointer(maTextureCoordLoc, 2, GLES20.GL_FLOAT, false, VERTEX_SZ, pTexCoord);
		GLES20.glEnableVertexAttribArray(maPositionLoc);
		GLES20.glEnableVertexAttribArray(maTextureCoordLoc);
	}

	/**
	 * terminatinng, this should be called in GL context
	 */
	public void release() {
		if (hProgram >= 0)
			GLES20.glDeleteProgram(hProgram);
		hProgram = -1;
	}
	
	/**
	 * draw specific texture with specific texture matrix
	 * @param tex_id texture ID
	 * @param tex_matrix texture matrix、if this is null, the last one use(we don't check size of this array and needs at least 16 of float)
	 */
	public void draw(int tex_id, float[] tex_matrix) {
		GLES20.glUseProgram(hProgram);
		if (tex_matrix != null)
			GLES20.glUniformMatrix4fv(muTexMatrixLoc, 1, false, tex_matrix, 0);
		GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
		GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, tex_id);
		GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, VERTEX_NUM);
		GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
        GLES20.glUseProgram(0);
	}
	
	/**
	 * create external texture
	 * @return texture ID
	 */
	public static int initTex() {
		if (DEBUG) Log.v(TAG, "initTex:");
		final int[] tex = new int[1];
		GLES20.glGenTextures(1, tex, 0);
		GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, tex[0]);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
				GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
				GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
				GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);
		GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
				GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_NEAREST);
		return tex[0];
	}

	/**
	 * delete specific texture
	 */
	public static void deleteTex(int hTex) {
		if (DEBUG) Log.v(TAG, "deleteTex:");
		final int[] tex = new int[] {hTex};
		GLES20.glDeleteTextures(1, tex, 0);
	}

	/**
	 * load, compile and link shader
	 * @param vss source of vertex shader
	 * @param fss source of fragment shader
	 * @return
	 */
	public static int loadShader(String vss, String fss) {
		if (DEBUG) Log.v(TAG, "loadShader:");
		int vs = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
		GLES20.glShaderSource(vs, vss);
		GLES20.glCompileShader(vs);
		final int[] compiled = new int[1];
		GLES20.glGetShaderiv(vs, GLES20.GL_COMPILE_STATUS, compiled, 0);
		if (compiled[0] == 0) {
			if (DEBUG) Log.e(TAG, "Failed to compile vertex shader:"
					+ GLES20.glGetShaderInfoLog(vs));
			GLES20.glDeleteShader(vs);
			vs = 0;
		}

		int fs = GLES20.glCreateShader(GLES20.GL_FRAGMENT_SHADER);
		GLES20.glShaderSource(fs, fss);
		GLES20.glCompileShader(fs);
		GLES20.glGetShaderiv(fs, GLES20.GL_COMPILE_STATUS, compiled, 0);
		if (compiled[0] == 0) {
			if (DEBUG) Log.w(TAG, "Failed to compile fragment shader:"
				+ GLES20.glGetShaderInfoLog(fs));
			GLES20.glDeleteShader(fs);
			fs = 0;
		}

		final int program = GLES20.glCreateProgram();
		GLES20.glAttachShader(program, vs);
		GLES20.glAttachShader(program, fs);
		GLES20.glLinkProgram(program);

		return program;
	}

}

これも大したことはしてないですが、OpenGL|ES2.0でView全面に描画するだけです。キーとなる点があるとすれば、SurfaceTexture#getTransformMatrixで取得したテクスチャ変換行列を使ってテクスチャ座標の変換をすることでしょうか。これを行わない場合、カメラのプレビュー画面が違う向きに表示されたり画面いっぱいに表示されなかったりします。

後は、以前の記事AndroidのGLES20.glUseProgramでプチハマりも見といてくださいね。

これでサンプルアプリ全体のやっと半分ぐらい(´・ω・`)

後はプレビュー描画のGLSurfaceView周りと、カメラ操作用のスレッド・ハンドラー、メイン画面用のフラグメントですけど、どんどん元テーマの「MediaCodec+MediaMuxerを使って音&動画の同時キャプチャがした0い」から離れていってしまうのでバッサリ省略です。単に疲れただけって話も・・・m(__)m 実際のところMediaCodec+MediaMuxerに直接関係ある部分はアプリ全体の1/3以下ぐらいで残りは今回のEGL/OpenGL|ES関係や、カメラ関係になります。気になる方はGitHubからサンプルプロジェクトをゲットして自分で動かしてみてくださいね。

そうだ、1つ忘れてました。

AndroidManifest.xmlに

	<uses-permission android:name="android.permission.RECORD_AUDIO"/>
	<uses-permission android:name="android.permission.CAMERA"/>
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

の記述が必要です。

	<uses-feature android:name="android.hardware.camera" />
	<uses-feature android:name="android.hardware.camera.autofocus" />

の2つは必須ではありませんが、対応する機種を絞りたい時には追加して下さい。ただしこの2つのuses-featureを追加すると、Nexus7(2012)のように固定焦点のインカメラのみしか持たない機種が除外されて実行できなくなってしまいます。

と言う事で、こんどこそ本当におしまいです。

お疲れ様でした。

USBカメラから音&動画の同時キャプチャした~い(その1)

先日、GitHubで公開しているライブラリ&サンプルプロジェクトUVCCameraに3つ目のサンプルプロジェクトを追加したので、その解説を書きたいと思います。

はじめに

まぁ予想はつくと思うと言うかタイトルに書いちゃってますけど、USB接続のカメラから映像を取り込んで、内蔵マイクの音声と一緒に動画(MPEG4)として保存するサンプルです。

簡単にいえば、これも先日公開した、内蔵カメラと内蔵マイクからMediaCodec&MediaMuxerで音声付きの動画を保存するサンプルプロジェクトAudioVideoRecordingSampleのUSBカメラ版です。と言っても同じ部分と違う部分が有るのでそこら辺を解説したいと思います。

ちなみに、USB接続のWebカメラから動画をキャプチャした0い(^o^)/0その3は公開する前に既に忘れ去られモード(^_^;)ちまちまと他の記事に分散して書いてしまったからなかったコトにしましょう。

全体像

screenshot_ 2014-09-02 6.12.38Javaのソースはこんな感じになっておりまする。

この内、encoderとglutils配下は前回記事

で紹介したAudioVideoRecordingSampleのと基本的に同じものです。EGLBaseとRenderHandlerは少しだけ修正してありますが、今回のサンプルとは直接関係ない変更です。

usb配下はDeviceFilter.javaのバグ修正とかがあるけど、USBCameraTesdt/USBCameraTest2と機能的には同じものです(USBCameraTesdt2、USBCameraTest3とUVCCamera/library/src以下に含まれているDeviceFilter.javaも修正してあります)。

では、どこが違うと言うと、MainActivity / UVCCameraTextureView / CameraViewInterfaceです。と言うより、CameraViewInterfaceは前2つのサンプルプロジェクトにはありませんでしたね。

では一番簡単な、CameraViewInterfaceから行きま0す(^o^)/

CameraViewInterface

と言ってもinterfaceの名の通り大したことはありません。元々有ったAspectRatioViewInterfaceを継承してキャプチャ用のメソッドを追加しています。まぁ実際のところはinterfaceにする必要はないのですが、作っている最中に違うタイプのviewもテストしていたのでその名残ですね。4つ目の#captureStillImage以外は、USBCameraTesdt2のUVCCameraTextureViewクラス内で既に定義していました。

package com.serenegiant.widget;
public interface CameraViewInterface extends AspectRatioViewInterface {
	public SurfaceTexture getSurfaceTexture();
	public boolean hasSurface();
	public void setVideoEncoder(final MediaVideoEncoder encoder);
	public Bitmap captureStillImage();
}

では次、少しでも簡単なMainActivityにしましょう。

MainActivity

USBカメラ関係の部分は基本的に同じなので省略です。一番違うのはキャプチャ関係です。

	/**
	 * start resorcing
	 * This is a sample project and call this on UI thread to avoid being complicated
	 * but basically this should be called on private thread because prepareing
	 * of encoder is heavy work
	 */
	private void startRecording() {
		if (DEBUG) Log.v(TAG, "startRecording:");
		try {
			mCaptureButton.setColorFilter(0xffff0000);	// turn red
			mMuxer = new MediaMuxerWrapper(".mp4");	// if you record audio only, ".m4a" is also OK.
			if (true) {
				// for video capturing
				new MediaVideoEncoder(mMuxer, mMediaEncoderListener);
			}
			if (true) {
				// for audio capturing
				new MediaAudioEncoder(mMuxer, mMediaEncoderListener);
			}
			mMuxer.prepare();
			mMuxer.startRecording();
		} catch (IOException e) {
			mCaptureButton.setColorFilter(0);
			Log.e(TAG, "startCapture:", e);
		}
	}

	/**
	 * request stop recording
	 */
	private void stopRecording() {
		if (DEBUG) Log.v(TAG, "stopRecording:mMuxer=" + mMuxer);
		mCaptureButton.setColorFilter(0);	// return to default color
		if (mMuxer != null) {
			mMuxer.stopRecording();
			mMuxer = null;
			// you should not wait here
		}
	}

	/**
	 * callback methods from encoder
	 */
	private final MediaEncoder.MediaEncoderListener mMediaEncoderListener = new MediaEncoder.MediaEncoderListener() {
		@Override
		public void onPrepared(MediaEncoder encoder) {
			if (DEBUG) Log.v(TAG, "onPrepared:encoder=" + encoder);
			if (encoder instanceof MediaVideoEncoder)
				mUVCCameraView.setVideoEncoder((MediaVideoEncoder)encoder);
		}

		@Override
		public void onStopped(MediaEncoder encoder) {
			if (DEBUG) Log.v(TAG, "onStopped:encoder=" + encoder);
			if (encoder instanceof MediaVideoEncoder)
				mUVCCameraView.setVideoEncoder(null);
		}
	};

AudioVideoRecordingSampleからコピーしたらメソッド名まで変わっちゃった(笑)説明は必要ないですね(^_^;) はいはい、サクサク行きましょう、後がつかえているので。次は静止画のキャプチャです。


ついでに、静止画のキャプチャも実装しました。プレビュー表示中にプレビュー画面を長押しするとキャプチャして外部ストレージ/DCIM/AVRecSample以下に実行時の時刻をファイル名にして保存します。

MainActivityとUVCCameraTextureViewの両方で処理が必要ですが、そのうちのMainActivity側の実装はこんな感じです。

	private void captureStillImage() {
		// capturing still image is heavy work on UI thread therefor you should use worker thread
		sEXECUTER.execute(new Runnable() {
			@Override
			public void run() {
				if (DEBUG) Log.v(TAG, "captureStillImage:");
				mSoundPool.play(mSoundId, 0.2f, 0.2f, 0, 0, 1.0f);	// play shutter sound
				final Bitmap bitmap = mUVCCameraView.captureStillImage();
				try {
					// get buffered output stream for saving a captured still image as a file on external storage.
					// the file name is came from current time.
					// You should use extension name as same as CompressFormat when calling Bitmap#compress.
					final BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(
							MediaMuxerWrapper.getCaptureFile(Environment.DIRECTORY_DCIM, ".png")));
					try {
						try {
							bitmap.compress(CompressFormat.PNG, 100, os);
							os.flush();
						} catch (IOException e) {
						}
					} finally {
						os.close();
					}
				} catch (FileNotFoundException e) {
				} catch (IOException e) {
				}
			}
		});
	}

ちゃんとシャッター音も鳴るようにしました。後はUVCCameraTextureViewクラスにお願いしてプレビュー画面をBitmapとして取得してpngにフォーマット変換しながらファイルに出力するだけです。

Webでよく見かけるコードだと、取得したFileOutputStream/FileInputStreamをそのまま使っちゃっているのが多く有るようですが、特にSDカードへの書き込みは遅いのでちゃんとバッファリングしましょう。ファイルへの書き出しの場合には、BufferedOutputStreamのコンストラクタにFileOutputStreamのインスタンスを引き渡すだけです。標準では8192バイトのバッファを使うようですが、必要であればコンストラクタで指定することも出来ます。

残りは特に説明の必要もない定番コードですね。

あっそうそう、Bitmapの取得もpngへの変換&出力も相対的に重い処理なのでUIスレッドで直接行うのは駄目です。ここでは、スレッドプール(ThreadPoolExecutor#executer)を使って実行していますが、別スレッドで動かせれば方法は問いません。でもこの為だけにAsyncTaskLoaderやAsyncTaskを実装したり別スレッド操作用にHandlerクラスを作ったりとかはありえへんとは思いますけどね。ちなみに、AsyncTaskLoaderも内部ではスレッドプールを使って実行しているようです。

ちなみに、スレッドプールは久しぶりの登場なので、初期化も載せてみましょう。static final変数として定義してあります。自分は短時間で終了するシリアライズの必要が無いマルチスレッド処理ではよく使います。

	// for thread pool
	private static final int CORE_POOL_SIZE = 1;		// initial/minimum threads
	private static final int MAX_POOL_SIZE = 4;			// maximum threads
	private static final int KEEP_ALIVE_TIME = 10;		// time periods while keep the idle thread
	protected static final ThreadPoolExecutor sEXECUTER
		= new ThreadPoolExecutor(CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME,
			TimeUnit.SECONDS, new LinkedBlockingQueue());

スレッドプールの初期個数やアイドル状態でもキープし続ける時間・個数は呼び出しの頻度等に合わせて調整してくださいね。上の例ではどんなに暇でも最低1個は常時待機、最大4個まで同時に処理ができるけどそれ以上の場合は空きができるまで呼び出しを待機、10秒以上暇になったスレッドは最後の1個以外は破棄されるって設定です。

	new Thread(new Runnable() {
		@Override
		public void run() {
			// 非同期処理
		}
	});

みたいなことをしても悪くは無いですが、Threadの生成自体が比較的重いですし、何よりもJavaの大敵、GCを少しでも減らすためにはスレッドプールは有用だと思います。

まぁあまり難しくなありませんでしたね。でも最後のUVCCameraTextureViewは超盛りだくさんです。

でも長くなるので次回にしましょう。

今回はここまで、お疲れ様でした。

GitHubで公開しているライブラリ&サンプルプロジェクトへのリンクはこちらUVCCamera

USBカメラから音&動画の同時キャプチャした~い(その2)

USBカメラから音&動画の同時キャプチャした0いの第2弾です。いよいよ佳境に入ってきました。

UVCCameraTextureView

実はこのクラスはAudioVideoRecordingSampleともUSBCameraTest/USBCameraTest2ともかなり異なっています。

そもそも、USBCameraTest/USBCameraTest2ではUVCCameraTextureViewにはviewのサイズ調整以外の処理をしていませんでした。 なのに不思議とプレビュー画面が表示できていたのです。 この辺りの処理は内蔵カメラでの場合と同じで、表示する枠組み(TextureViewやSurfaceView)さえ用意しておけばほとんどの処理をカメラ処理側で行ってくれていたのです。 と言うより同じになるように作ったんですけどね(^_^)

前回のUSBCameraTest2では、MediaCodecのキャプチャ用のSurfaceに対しても同じようにしてカメラ側で映像を書き込むための関数・メソッドを追加しました。

	public void UVCCamera#startCapture(Surface surface);
	public void UVCCamera#stopCapture();

でも今回はこの関数・メソッドを使っていません。別に使ってもいいんだけど殆ど同じサンプルじゃ意味無いでしょ?AudioVideoRecordingSampleではGLSurfaceViewベースでしたが、今回はTextureViewベースでJava側でプレビュー用と録画用の2回描画するようにしました。

利点

AudioVideoRecordingSampleでは他に方法がなかったからですが、でも何でJava側でわざわざ描画しているんでしょう?

このサンプルでは実装していませんが、例えば白黒とかセピア色にするなどの画像の後処理・変換したり、他の画像や文字情報とかを書き込んで表示・保存したい場合もありますよね?

もちろんnative側でも個別に変換処理を追加すれば好きに変換できますけど、そうすると変換方法や変換の設定を追加するたびにnative側の共有ライブラリを変更しないと駄目になっちゃうし、そもそもそういう変換の必要の無い用途も有りますよね。無駄に共有ライブラリを肥大化させるのはメンテナンスや互換性の点で非効率的ですよね。

後はJava(SDK)とC/C++(NDK)の両方を使いこなせる人って少なく無いですか?libs下に共有ライブラリをコピーするのは簡単でも、nativeの共有ライブラリ側の中身をいちいち変更するのは面倒・出来ないと言う人も多いと思うんですよね。そこで、その気になればJava側だけで上のような変換処理もできるサンプルを提供することで、少しでも敷居を下げたいと思うんですよね。内蔵カメラ向けにも使いまわせるし、頑張ればARやバーコードの読込みとかも出来ますよ。

欠点

一方欠点はって言うと、やっぱり何と言ってもJavaで実行すると速度的に不利です。JITコンパイラもVMも優秀になってきて差はだいぶ縮まってきましたが、GCは避けて通れないし。でも最近の端末であれば、映像をJava側へ取り出さずに、OpenGL|ESでGPU内にテクスチャとして保持したまま処理する分には、気にしなくても良くなってきたかな0って思います。

と言う事でソースです。じゃじゃ0ん(^o^)/まずは描画部分からです。今回の記事は描画部分が殆どと言う話も有りますが

AudioVideoRecordingSampleでは描画にGLSurfaceViewの子クラスを使っていたので、SurfaceTextureの生成やプレビュー表示はGLSurfaceViewのレンダリングコンテキスト内で行っていました。でも、今回はTextureViewです。そのままではOpenGL|ESの描画ができません。標準だとUIスレッドのレンダリングコンテキスト内で描画が行われてしまうという話もあるらしいので、描画用のスレッドとハンドラーを作ってその中で専用のレンダリングコンテキストを生成して描画処理を行うようにしました。処理内容はAudioVideoRecordingSampleとほぼ同じです。

	/**
	 * render camera frames on this view on a private thread
	 */
	private static final class RenderHandler extends Handler
		implements SurfaceTexture.OnFrameAvailableListener  {

		private static final int MSG_REQUEST_RENDER = 1;
		private static final int MSG_SET_ENCODER = 2;
		private static final int MSG_CREATE_SURFACE = 3;
		private static final int MSG_TERMINATE = 9;

		private RenderThread mThread;
		private boolean mIsActive = true;

		public static final RenderHandler createHandler(SurfaceTexture surface) {
			final RenderThread thread = new RenderThread(surface);
			thread.start();
			return thread.getHandler();
		}

		private RenderHandler(RenderThread thread) {
			mThread = thread;
		}

		public final void setVideoEncoder(MediaVideoEncoder encoder) {
			if (mIsActive)
				sendMessage(obtainMessage(MSG_SET_ENCODER, encoder));
		}

		public final SurfaceTexture getPreviewTexture() {
			synchronized (mThread.mSync) {
				sendEmptyMessage(MSG_CREATE_SURFACE);
				try {
					mThread.mSync.wait();
				} catch (InterruptedException e) {
				}
				return mThread.mPreviewSurface;
			}
		}


		public final void release() {
			if (mIsActive) {
				mIsActive = false;
				removeMessages(MSG_REQUEST_RENDER);
				removeMessages(MSG_SET_ENCODER);
				sendEmptyMessage(MSG_TERMINATE);
			}
		}

		@Override
		public final void onFrameAvailable(SurfaceTexture surfaceTexture) {
			if (mIsActive)
				sendEmptyMessage(MSG_REQUEST_RENDER);
		}

		@Override
		public final void handleMessage(Message msg) {
			if (mThread == null) return;
			switch (msg.what) {
			case MSG_REQUEST_RENDER:
				mThread.onDrawFrame();
				break;
			case MSG_SET_ENCODER:
				mThread.setEncoder((MediaVideoEncoder)msg.obj);
				break;
			case MSG_CREATE_SURFACE:
				mThread.updatePreviewSurface();
				break;
			case MSG_TERMINATE:
				Looper.myLooper().quit();
				mThread = null;
				break;
			default:
				super.handleMessage(msg);
			}
		}

長いのでちょっと分割っす。


まだまだ続くよぉ0(^o^)/

		private static final class RenderThread extends Thread {
			private final Object mSync = new Object();
			private SurfaceTexture mSurface;
			private RenderHandler mHandler;
			private EGLBase mEgl;
			private EGLBase.EglSurface mEglSurface;
			private GLDrawer2D mDrawer;
			private int mTexId = -1;
			private SurfaceTexture mPreviewSurface;
			private final float[] mStMatrix = new float[16];
			private MediaVideoEncoder mEncoder;

			/**
			 * constructor
			 * @param surface: drawing surface came from TexureView
			 */
			public RenderThread(SurfaceTexture surface) {
				mSurface = surface;
				setName("RenderThread");
			}

			public final RenderHandler getHandler() {
				if (DEBUG) Log.v(TAG, "RenderThread#getHandler:");
				synchronized (mSync) {
					// create rendering thread
					if (mHandler == null)
					try {
						mSync.wait();
					} catch (InterruptedException e) {
					}
				}
				return mHandler;
			}

			public final void updatePreviewSurface() {
				if (DEBUG) Log.i(TAG, "updatePreviewSurface:");
				synchronized (mSync) {
					if (mPreviewSurface != null) {
						if (DEBUG) Log.d(TAG, "release mPreviewSurface");
						mPreviewSurface.setOnFrameAvailableListener(null);
						mPreviewSurface.release();
						mPreviewSurface = null;
					}
					mEglSurface.makeCurrent();
					if (mTexId >= 0) {
						GLDrawer2D.deleteTex(mTexId);
					}
					// create texture and SurfaceTexture for input from camera
					mTexId = GLDrawer2D.initTex();
					if (DEBUG) Log.v(TAG, "getPreviewSurface:tex_id=" + mTexId);
					mPreviewSurface = new SurfaceTexture(mTexId);
					mPreviewSurface.setOnFrameAvailableListener(mHandler);
					// notify to caller thread that previewSurface is ready
					mSync.notifyAll();
				}
			}

			public final void setEncoder(MediaVideoEncoder encoder) {
				if (encoder != null) {
					encoder.setEglContext(mEglSurface.getContext(), mTexId);
				}
				mEncoder = encoder;
			}

			/**
			 * draw a frame (and request to draw for video capturing if it is necessary)
			 */
			public final void onDrawFrame() {
				mEglSurface.makeCurrent();
				// update texture(came from camera)
				mPreviewSurface.updateTexImage();
				// get texture matrix
				mPreviewSurface.getTransformMatrix(mStMatrix);
				if (mEncoder != null) {
					// notify to capturing thread that the camera frame is available.
					mEncoder.frameAvailableSoon(mStMatrix);
				}
				// draw to preview screen
				mDrawer.draw(mTexId, mStMatrix);
				mEglSurface.swap();
			}

			@Override
			public final void run() {
				Log.d(TAG, getName() + " started");
				init();
				Looper.prepare();
				synchronized (mSync) {
					mHandler = new RenderHandler(this);
					mSync.notify();
				}

				Looper.loop();

				Log.d(TAG, getName() + " finishing");
				release();
				synchronized (mSync) {
					mHandler = null;
					mSync.notify();
				}
			}

			private final void init() {
				if (DEBUG) Log.v(TAG, "RenderThread#init:");
				// create EGLContext for this thread
				mEgl = new EGLBase(null, false);
				mEglSurface = mEgl.createFromSurface(mSurface);
				mEglSurface.makeCurrent();
				// create drawing object
				mDrawer = new GLDrawer2D();
			}

			private final void release() {
				if (DEBUG) Log.v(TAG, "RenderThread#release:");
				if (mDrawer != null) {
					mDrawer.release();
					mDrawer = null;
				}
				if (mPreviewSurface != null) {
					mPreviewSurface.release();
					mPreviewSurface = null;
				}
				if (mTexId >= 0) {
					GLDrawer2D.deleteTex(mTexId);
					mTexId = -1;
				}
				if (mEglSurface != null) {
					mEglSurface.release();
					mEglSurface = null;
				}
				if (mEgl != null) {
					mEgl.release();
					mEgl = null;
				}
			}
		}
	}

src/com/serenegiant/glutils下にも同名のクラスがありますが、こっちのはUVCCameratextureViewのインナークラスです。内容的にも似たような処理をするので、glutils下のRenderHandlerを修正しようかとも思ったんですけどしっくり来なかったので結局インナークラスとして別に作ってしまいました。いつもの様にプライベートスレッドとメッセージキューを使ってシリアライズして実行依頼するためのHandlerの組み合わせになっています。

ちなみに、TextureViewから取得したSurfaceTextureへ直接カメラ映像を書き込んでもらうことも出来ますが(Surface/SurfaceTextureへの書き込み回数が1回減ります)、ここではカメラ側に映像を書き込んでもらうためのSurfaceTextureを描画用のプライベートスレッド内で新たに生成し、それを自前でTextureViewからのSurfaceTextureとMediaCodecへ書き込んでいます。

というのも、TextureViewから取得したSurfaceTextureをそのまま使った時に、実際の描画処理がどのスレッドで行われるか・・・実際の所UIスレッドで実行されちゃうんじゃないかと思ったからです(未確認、そもそもUSBCameraTest/USBCameraTest2ともTextureViewから取得したSurfaceTextureへ直接書き込んでいるのでこのアプリでは大きな問題にならないのは実証済みですが)。

なので、MediaStorePhotoAdapterの時にカメラ映像取得用のSurfaceTextureを生成したのとは若干意味合いが異なります。言っている意味判るかな?

後は特に今までの記事で出てきてない引っかかったところは無いと思うんだけど。しいてあげるなら、カメラ映像取得用のSurfaceTextureを生成・取得している部分かな?

SurfaceTextureを自前で生成するにはOpenGL|ESのテクスチャハンドルが必要になります。でもって、OpenGL|ESのテクスチャハンドルを生成するにレンダリングコンテキスト内で実行する必要があります。今回の場合であれば、描画用のプライベートスレッド内でeglMakeCurrentした状態で無ければなりません。でも、UVCCameraTextureView#getSurfaceTextureを呼び出すのはどのスレッドか判りません(実際はMainActivityでカメラとの接続イベントOnDeviceConnectListener#onConnectが呼び出された時に、スレッドプール内のスレッドで実行されます)。ということは、呼び出されたそのスレッド内ではSurfaceTextureを生成出来ません。「いっぺんしか言わんけどコンテキストが無いんじゃぁ0」って警告メッセージがLogCatに残るだけでクラッシュはしなかったと思うけど、表示できなくなります。

なのでまず、

UVCCameraTextureView#getSurfaceTextureは次のようにしています。

	@Override
	public SurfaceTexture getSurfaceTexture() {
		return mRenderHandler != null ? mRenderHandler.getPreviewTexture() : super.getSurfaceTexture();
	}

ここは単にRenderHandler#getPreviewTextureを呼び出しているだけです。

呼び出されたRenderHandler#getPreviewTextureはと言うと、次にようにしています。

	public final SurfaceTexture getPreviewTexture() {
		synchronized (mThread.mSync) {
			sendEmptyMessage(MSG_CREATE_SURFACE);
			try {
				mThread.mSync.wait();
			} catch (InterruptedException e) {
			}
			return mThread.mPreviewSurface;
		}
	}

まずはじめにsynchronizedで排他制御してからsendEmptyMessageでSurfaceTexture生成依頼メッセージを送ります。その後#waitメソッドでSurfaceTextureが生成されるまで待機します。

メッセージはRenderHandler#handleMessageで処理されてRenderThread#updatePreviewSurfaceを呼び出します。この時点で#updatePreviewSurfaceは描画用のプライベートスレッド内で実行されることになりますので、自前のレンダリングコンテキストに切り替えてテクスチャハンドル・SurfaceTextureを生成します。完了すれば#notifyAllを呼び出して元のスレッドへ通知・待機解除します。

てな事は読んでもたぶんすんなりとはわかってもらえないだろうなぁ(-_-;)気になる方はソースをじっくり煮込んで下さい、じゃなかったじっくり眺めて処理を追っかけてみて下さい。デバッガで追っかけても大抵はわけわかんない結果になるだけだし、USBの通信も止まっちゃうからね。各ファイルのDEBUGフラグをtrueにしていればLogCatへいっぱいメッセージが出るので、それを追っかけて見るのもいいかも。

んでもって最後は静止画のキャプチャの残りです。


静止画のキャプチャ

前回の記事で載せた静止画のキャプチャ処理のUVCCameraTextureView側です。全部をMainActivity側へ持って行くことも出来ますけど今回は別々に分けてます。

まずフィールドを3つ定義します。このうちの2つ目のBitmapは必須では無いですが、呼び出し頻度が高い場合を考慮してOOMを避けるためにこういう実装の方が良いと思います。

その代わり複数スレッドからほぼ同時に静止画キャプチャ要求をすると、最初に取得したビットマップを処理中に次のスレッドが中身を書き換えてしまうかもしれません。複数スレッドから呼び出す可能性があるのなら、UVCCameraTextureViewのプライベートフィールドでBitmapオブジェクトを保持するのではなく、呼び出し側からBitmapオブジェクトを供給できるように作り変えるほうがよいと思います。

	private final Object mCaptureSync = new Object();
	private Bitmap mTempBitmap;
	private boolean mReqesutCaptureStillImage;

次にMainActivityから呼び出していた#captureStillImageメソッドです。これはRenderHandler#getPreviewTextureと似ていますが、メッセージを送るのでは無く単純にフラグを立てて待機します。最新状態でなくても構わなければ直接TextureView#getBitmapを呼び出しても良かったと思いますが、ここでは要求直後の最新画像を取得するためにこのような実装にしています。

	@Override
	public Bitmap captureStillImage() {
		synchronized (mCaptureSync) {
			mReqesutCaptureStillImage = true;
			try {
				mCaptureSync.wait();
			} catch (InterruptedException e) {
			}
			return mTempBitmap;
		}
	}

実際のビットマップの取得処理がどこで行われるかというと、TextureView.SurfaceTextureListener#onSurfaceTextureUpdatedになります。UVCCameraTextureViewのコンストラクタで#setSurfaceTextureListenerを呼び出して登録しています。このメソッドは、TextureViewの描画用SurfaceTextureが更新された時(直接的または間接的にSurfaceTexture#updateTexImageが呼び出された時)に呼ばれます。

	@Override
	public void onSurfaceTextureUpdated(SurfaceTexture surface) {
		synchronized (mCaptureSync) {
			if (mReqesutCaptureStillImage) {
				mReqesutCaptureStillImage = false;
				if (mTempBitmap == null)
					mTempBitmap = getBitmap();
				else
					getBitmap(mTempBitmap);
				mCaptureSync.notifyAll();
			}
		}
	}

ここも同じように排他制御した上でTextureView#getBitmapを呼び出して、表示内容をビットマップとして取得します。後はnotifyAllを呼び出して呼び出し元の待機解除すれば終了です。

2014/09/23 追記

上のソースでキャプチャしたビットマップは、Viewと同じ大きさになります。もし、特定の大きさにしたいのであれば、7行目を

mTempBitmap = getBitmap(横幅, 高さ);

に変更して下さい。以降の呼び出しては、mTempBitmapが最初に生成された時の大きさになります。もし取得するビットマップのサイズを変更したい時には、mTempBitmapを開放すると、もう一度サイズ指定が可能になります。

2014/09/23 追記ここまで

ところで、TextureView#getBitmapの呼び出しはちょっと注意が必要です。TextureView#getBitmapには次の3通りが有ります。

	public Bitmap getBitmap(int width, int height);
	public Bitmap getBitmap();
	public Bitmap getBitmap(Bitmap bitmap);

この内上2つのメソッドは、毎回新たにBitmapオブジェクトを生成します。今回のような低頻度のワンショット処理であればあまり気にしなくても大丈夫ですが、#onSurfaceTextureUpdatedはSurfaceTextureが更新されるたびに呼び出されることを覚えておいて下さい。仮に毎回上2つの#getBitmapメソッドのいずれかを呼び出してしまうと、あっという間にOOMでクラッシュします。一方、3つ目の#getBitmapは同じBitmapオブジェクトを使いまわすので毎回呼び出してもOOMにはなりにくいです。

もっともAndroidのBitmapと言えばかなり遅いことで有名なので、高頻度でイメージを取得しないといけない理由が有るのなら他の方法を考えたほうが良いと思います。

細かいメソッドはまだ少し有りますが、主なものは以上ですね。

RenderThread内のレンダリングコンテキスト生成・破棄・描画のタイミングに合わせて呼び出すようなリスナーインターフェース(GLSurfaceView.Rendererみたいな奴)を作れば、TextureView版のGLSurfaceViewになります。まぁ需要があるかどうかは別というか、グルグル先生的には必要無い・優先度が低いと言う事でSDKに含まれていないんでしょうけどね(-_-;)

AudioVideoRecordingSample / USBCameraTest / USBCameraTest2の記事で大部分は説明しているので、意外と簡単に終わってしまいました。相変わらずマルチスレッドバリバリなので、そこら辺が苦手な人には簡単どころではないのかもしれないですけどね。

と言う事で、おしまいです。

お疲れ様でした。

USBカメラから音&動画の同時キャプチャした~い(その3)

GitHubで公開している、UVCカメラへのアクセス用ライブラリ&サンプルプロジェクト、UVCCameraに含まれるサンプルUSBCameraTest3についての追加記事です。

最初の公開時にUSBCameraTest3のMainActivityのstartRecordingにコメントとして、プライベートスレッドで実行した方が良いよと書いていましたが、プライベートスレッドで実行するように変更したので、ちょびっと解説を。

何でプライベートスレッドやねん?

そうそう、これが大事なんです。MediaCodec+MediaMuxerを使う今までのサンプルにも書いて来ましたが、特にMediaMuxerを使う場合において、プライベートスレッドで実行するのが結構大事なのです。

  • MediaCodecの初期化処理は結構重い
  • MediaCodecの終了処理も結構重い
  • MediaMuxerの終了処理も結構重い・・・しかもいつ終わるかわからない

と言う事で、エンコーダー周りのスレッド構成はこんな感じになっています。以前の記事音&動画の同時キャプチャがした~い(その1)からの流用です。


MediaCodec/MediaMuxerに関係するスレッド

USBCameraTest3についてもエンコーダー周りは同じように実装してあります。

それだったら既にプライベートスレッドで動いてるんやないん?って思ったそこのあなた、エンコーダー周りについてはその通りです。

でもUSBCameraTest3では少なくともあと2つばかりプライベートスレッドで実行した方が良い処理があったのです。

  • 1つ目は簡単に想像付くと思いますが、カメラ(open/close、プレビュー表示開始/停止など)の処理
  • 2つ目は今までのサンプルでは知らなかった事にして実装していなかった処理(笑)

カメラの処理

1つ目のカメラの処理は説明するまでも無いとは思いますが、2つの理由があります。

処理に時間がかかる

逐次的に処理をしないといけない。例えばカメラをopenする前にプレビュー表示開始してはいけません。

つまり、時間のかかる処理を待機しないといけないと言うことになるのですが、UIスレッドで待機するとANRになってしまいます。今までのサンプルでは、手動のボタン操作で開始指示してThreadPoolで単発処理していたので問題無いように見えていましたが、流石に処理が複雑になってきたので、そろそろ真面目に実装すべきでしょうね。

逐次的に処理・・・処理のシリアライズが必要ってことですが、オブジェクトのシリアライズと混ぜこぜにしてしまう頑固なJava使いも多いようなので、シーケンシャルに処理って言いましょう(笑)意味的にはオブジェクトのシリアライズの方が異端な気がしますけどね。

で、そのシーケンシャルに実行する方法はいくつもあるので好きな方法でいいのですが、Android&プライベートスレッド&シーケンシャル実行となると、定番はHandlerを使ったメッセージベースの方法ですね。FIFOのメッセージキューを使うことで勝手にシーケンシャル実行になります。このブログでも過去に何度も登場していますので、詳細は省略です。

知らなかった事にして実装していなかった処理

そして2つ目、「知らなかった事にして実装していなかった処理」です。この処理自体はプライベートスレッドで実行しなくてもいいのですが、MediaMuxerを使う場合はプライベートスレッドから呼び出すほうが楽になることがあります。

一体何のことか判りましたか?じゃじゃ0ん。それは、メディアスキャン処理です(もちろん知らなかった訳では無く単に実装するのが面倒だっただけ)。Android特有の事情でメディアスキャン処理をしてあげないと、せっかく静止画や動画と保存しても、例えばGalleryアプリやPhotoアプリで見ることが出来ないのです(少なくとも端末を再起動すれば見れるようになります)。ファイラー系のアプリでは見えるのに、Galleryアプリでは見えない・・・それはメディアスキャン処理を行ってないからなのです。

メディアスキャン処理はいくつか方法が有ります。

  • 一番簡単なのはMediaScannerConnection#scanFileを使う方法だと思います。
  • 一番詳細にコントロールできるのは、ContentResolver経由で必要な情報をMediaStoreへ登録する方法

昔は出来たけど確か今は通常のアプリからは出来なくなったのが、ACTION_MEDIA_MOUNTEDをブロードキャストする方法

今回は、一番簡単なMediaScannerConnection#scanFileを使います。

でも、MediaScannerConnectionでの処理は静止画や動画のファイルが出来てからでないと行うことが出来ません。静止画の方はファイルへの出力までほぼ自前でコントロールして行うので、ファイルへ書き出しが終わったタイミングでMediaScannerConnection#scanFileを呼び出せばいいのですが、問題は動画です。

最初の方で書きましたが、「MediaMuxerの終了処理も結構重い・・・しかもいつ終わるかわからない」のです。しかも、MediaScannerConnectionの処理はContextが必要なのです。別にActivityのコンテキストである必要は無くて、アプリケーションコンテキストで十分なのですが。

通常はMediaMuxerへ終了指示を出してから大体においては10数秒後には終わりますが、その時点でアクティビティやアプリが生きているかどうかもわからないのです。例えば録画中にバックキーを押してアプリを終了してしまった時、あるいは、録画終了指示を出した直後にアプリ終了した時など、MediaMuxerの処理が完了している保証は誰にも出来ません。この、MediaMuxer終了時に「アプリが生きているかどうかもわからない」ってのが曲者で色々手をかけてあげなければいけません。

例えば、MediaMuxerへの強参照をいつ破棄するかが問題になります。強参照が無くなってしばらくすると処理中であっても強制的にGCに抹殺されてしまうので、MediaMuxerが処理を終了するのに十分なだけ強参照で保持してあげないといけません。元々のUSBCameraTest3の実装だと機種によってはMediaMuxerが処理完了する前にGCに抹殺される場合がありました。

1つ前のサンプルUSBCameraTest2の場合は使っているMediaCodec/MediaMuxerが映像用の1つだけだったので、エンコーダー用のスレッドでコントロールすれば良かったのですが、USBCameraTest3ではMediaCodecが音声用と映像用の2つあって、それぞれが独立してMediaMuxerへ書き込みに行くので、誰かが別に音頭を取って破棄のタイミングを図る必要あるのです。

そしてそのMediaMuxerが処理を終了してからMediaScannerConnectionを呼び出せるようにコンテキストを参照可能な状態で保持しないといけないのですが、こちらは気を付けないととリークする可能性があります(破棄されるべきオブジェクトが破棄されずに残ってしまう状態)。

USBCameraTest3では、MediaCodec/MediaMuxer周りの処理をMediaMuxerWrapper内のスレッドで実行しているので、そこに組み込んでもいいのですが、

  • どのみちカメラ操作用のスレッドを作らないといけない
  • MediaMuxerWrapperへコンテキストを渡さないといけなくなる
  • メディアスキャン処理は、エンコード処理と直接関係ない・・・どちらかと言うと上位で呼び出す方が良い処理

とかの理由で、MainActivity内に別スレッドの処理を追加実装しました。

と言う事でソースです。わっはっは、説明が面倒になってきたのでソースで誤魔化そう。長くなってきたので、そろそろソースに行きましょう。


元々のサンプルのMainActivity内でThreadPoolで実行していたのと同等の処理内容ですが、Thread+Handlerを使ってシーケンシャルに実行できるように変更しました。

	private static final class CameraHandler extends Handler {
		private static final int MSG_OPEN = 0;
		private static final int MSG_CLOSE = 1;
		private static final int MSG_PREVIEW_START = 2;
		private static final int MSG_PREVIEW_STOP = 3;
		private static final int MSG_CAPTURE_STILL = 4;
		private static final int MSG_CAPTURE_START = 5;
		private static final int MSG_CAPTURE_STOP = 6;
		private static final int MSG_MEDIA_UPDATE = 7;
		private static final int MSG_RELEASE = 9;

		private final WeakReference mWeakThread;

		public static final CameraHandler createHandler(MainActivity parent, CameraViewInterface cameraView) {
			CameraThread thread = new CameraThread(parent, cameraView);
			thread.start();
			return thread.getHandler(); 
		}

		private CameraHandler(CameraThread thread) {
			mWeakThread = new WeakReference(thread);
		}

		public boolean isCameraOpened() {
			final CameraThread thread = mWeakThread.get();
			return thread != null ? thread.isCameraOpened() : false;
		}

		public boolean isRecording() {
			final CameraThread thread = mWeakThread.get();
			return thread != null ? thread.isRecording() :false;
		}

		public void openCamera(UsbControlBlock ctrlBlock) {
			sendMessage(obtainMessage(MSG_OPEN, ctrlBlock));
		}

		public void closeCamera() {
			stopPreview();
			sendEmptyMessage(MSG_CLOSE);
		}

		public void startPreview(Surface sureface) {
			if (sureface != null)
				sendMessage(obtainMessage(MSG_PREVIEW_START, sureface));
		}

		public void stopPreview() {
			stopRecording();
			final CameraThread thread = mWeakThread.get();
			if (thread == null) return;
			synchronized (thread.mSync) {
				sendEmptyMessage(MSG_PREVIEW_STOP);
				// wait for actually preview stopped to avoid releasing Surface/SurfaceTexture
				// while preview is still running.
				// therefore this method will take a time to execute
				try {
					thread.mSync.wait();
				} catch (InterruptedException e) {
				}
			}
		}

		public void captureStill() {
			sendEmptyMessage(MSG_CAPTURE_STILL);
		}

		public void startRecording() {
			sendEmptyMessage(MSG_CAPTURE_START);
		}

		public void stopRecording() {
			sendEmptyMessage(MSG_CAPTURE_STOP);
		}

/*		public void release() {
			sendEmptyMessage(MSG_RELEASE);
		} */

		@Override
		public void handleMessage(Message msg) {
			final CameraThread thread = mWeakThread.get();
			if (thread == null) return;
			switch (msg.what) {
			case MSG_OPEN:
				thread.handleOpen((UsbControlBlock)msg.obj);
				break;
			case MSG_CLOSE:
				thread.handleClose();
				break;
			case MSG_PREVIEW_START:
				thread.handleStartPreview((Surface)msg.obj);
				break;
			case MSG_PREVIEW_STOP:
				thread.handleStopPreview();
				break;
			case MSG_CAPTURE_STILL:
				thread.handleCaptureStill();
				break;
			case MSG_CAPTURE_START:
				thread.handleStartRecording();
				break;
			case MSG_CAPTURE_STOP:
				thread.handleStopRecording();
				break;
			case MSG_MEDIA_UPDATE:
				thread.handleUpdateMedia((String)msg.obj);
				break;
			case MSG_RELEASE:
				thread.handleRelease();
				break;
			default:
				throw new RuntimeException("unsupported message:what=" + msg.what);
			}
		}

		private static final class CameraThread extends Thread {
			private static final String TAG_THREAD = "CameraThread";
			private final Object mSync = new Object();
			private final WeakReference mWeakParent;
			private final WeakReference mWeakCameraView;
			private boolean mIsRecording;
			/**
			 * shutter sound
			 */
			private SoundPool mSoundPool;
			private int mSoundId;
			private CameraHandler mHandler;
			/**
			 * for accessing UVC camera 
			 */
			private UVCCamera mUVCCamera;
			/**
			 * muxer for audio/video recording
			 */
			private MediaMuxerWrapper mMuxer;

			private CameraThread(MainActivity parent, CameraViewInterface cameraView) {
				super(TAG_THREAD);
				mWeakParent = new WeakReference(parent);
				mWeakCameraView = new WeakReference(cameraView);
				loadSutterSound(parent);
			}

			@Override
			protected void finalize() throws Throwable {
				Log.i(TAG, "CameraThread#finalize");
				super.finalize();
			}

			public CameraHandler getHandler() {
				if (DEBUG) Log.v(TAG_THREAD, "getHandler:");
				synchronized (mSync) {
					if (mHandler == null)
					try {
						mSync.wait();
					} catch (InterruptedException e) {
					}
				}
				return mHandler;
			}

			public boolean isCameraOpened() {
				synchronized (mSync) {
					return mUVCCamera != null;
				}
			}

			public boolean isRecording() {
				synchronized (mSync) {
					return (mUVCCamera != null) && (mMuxer != null);
				}
			}

			public void handleOpen(UsbControlBlock ctrlBlock) {
				if (DEBUG) Log.v(TAG_THREAD, "handleOpen:");
				handleClose();
				final UVCCamera camera = new UVCCamera();
				camera.open(ctrlBlock);
				synchronized (mSync) {
					mUVCCamera = camera;
				}
			}

			public void handleClose() {
				if (DEBUG) Log.v(TAG_THREAD, "handleClose:");
				handleStopRecording();
				final UVCCamera camera;
				synchronized (mSync) {
					camera = mUVCCamera;
					mUVCCamera = null;
				}
				if (camera != null) {
					camera.stopPreview();
					camera.destroy();
				}
			}

			public void handleStartPreview(Surface surface) {
				if (DEBUG) Log.v(TAG_THREAD, "handleStartPreview:");
				synchronized (mSync) {
					if (mUVCCamera == null) return;
					mUVCCamera.setPreviewDisplay(surface);
					mUVCCamera.startPreview();
				}
			}

			public void handleStopPreview() {
				if (DEBUG) Log.v(TAG_THREAD, "handleStopPreview:");
				synchronized (mSync) {
					if (mUVCCamera != null) {
						mUVCCamera.stopPreview();
					}
					mSync.notifyAll();
				}
			}

			public void handleCaptureStill() {
				if (DEBUG) Log.v(TAG_THREAD, "handleCaptureStill:");
				final MainActivity parent = mWeakParent.get();
				if (parent == null) return;
				mSoundPool.play(mSoundId, 0.2f, 0.2f, 0, 0, 1.0f);	// play shutter sound
				final Bitmap bitmap = mWeakCameraView.get().captureStillImage();
				try {
					// get buffered output stream for saving a captured still image as a file on external storage.
					// the file name is came from current time.
					// You should use extension name as same as CompressFormat when calling Bitmap#compress.
					final File outputFile = MediaMuxerWrapper.getCaptureFile(Environment.DIRECTORY_DCIM, ".png"); 
					final BufferedOutputStream os = new BufferedOutputStream(new FileOutputStream(outputFile));
					try {
						try {
							bitmap.compress(CompressFormat.PNG, 100, os);
							os.flush();
							mHandler.sendMessage(mHandler.obtainMessage(MSG_MEDIA_UPDATE, outputFile.getPath()));
						} catch (IOException e) {
						}
					} finally {
						os.close();
					}
				} catch (FileNotFoundException e) {
				} catch (IOException e) {
				}
			}

			public void handleStartRecording() {
				if (DEBUG) Log.v(TAG_THREAD, "handleStartRecording:");
				try {
					synchronized (mSync) {
						if ((mUVCCamera == null) || (mMuxer != null)) return;
						mMuxer = new MediaMuxerWrapper(".mp4");	// if you record audio only, ".m4a" is also OK.
					}
					if (true) {
						// for video capturing
						new MediaVideoEncoder(mMuxer, mMediaEncoderListener);
					}
					if (true) {
						// for audio capturing
						new MediaAudioEncoder(mMuxer, mMediaEncoderListener);
					}
					mMuxer.prepare();
					mMuxer.startRecording();
				} catch (IOException e) {
					Log.e(TAG, "startCapture:", e);
				}
			}

			public void handleStopRecording() {
				if (DEBUG) Log.v(TAG_THREAD, "handleStopRecording:mMuxer=" + mMuxer);
				final MediaMuxerWrapper muxer;
				synchronized (mSync) {
					muxer = mMuxer;
					mMuxer = null;
				}
				if (muxer != null) {
					muxer.stopRecording();
					// you should not wait here
				}
			}

			public void handleUpdateMedia(final String path) {
				if (DEBUG) Log.v(TAG_THREAD, "handleUpdateMedia:path=" + path);
				final MainActivity parent = mWeakParent.get();
				if (parent != null && parent.getApplicationContext() != null) {
					try {
						if (DEBUG) Log.i(TAG, "MediaScannerConnection#scanFile");
						MediaScannerConnection.scanFile(parent.getApplicationContext(), new String[]{ path }, null, null);
					} catch (Exception e) {
						Log.e(TAG, "handleUpdateMedia:", e);
					}
					if (parent.isDestroyed())
						handleRelease();
				} else {
					Log.w(TAG, "MainActivity already destroyed");
					// give up to add this movice to MediaStore now.
					// Seeing this movie on Gallery app etc. will take a lot of time. 
					handleRelease();
				}
			}

			public void handleRelease() {
				if (DEBUG) Log.v(TAG_THREAD, "handleRelease:");
 				handleClose();
				if (!mIsRecording)
					Looper.myLooper().quit();
			}

			private final MediaEncoder.MediaEncoderListener mMediaEncoderListener = new MediaEncoder.MediaEncoderListener() {
				@Override
				public void onPrepared(MediaEncoder encoder) {
					if (DEBUG) Log.v(TAG, "onPrepared:encoder=" + encoder);
					mIsRecording = true;
					if (encoder instanceof MediaVideoEncoder)
					try {
						mWeakCameraView.get().setVideoEncoder((MediaVideoEncoder)encoder);
					} catch (Exception e) {
						Log.e(TAG, "onPrepared:", e);
					}
				}

				@Override
				public void onStopped(MediaEncoder encoder) {
					if (DEBUG) Log.v(TAG_THREAD, "onStopped:encoder=" + encoder);
					if (encoder instanceof MediaVideoEncoder)
					try {
						mIsRecording = false;
						final MainActivity parent = mWeakParent.get();
						mWeakCameraView.get().setVideoEncoder(null);
						final String path = encoder.getOutputPath();
						if (!TextUtils.isEmpty(path)) {
							mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_MEDIA_UPDATE, path), 1000);
						} else {
							if (parent == null || parent.isDestroyed()) {
								handleRelease();
							}
						}
					} catch (Exception e) {
						Log.e(TAG, "onPrepared:", e);
					}
				}
			};

			/**
			 * prepare and load shutter sound for still image capturing
			 */
			private void loadSutterSound(Context context) {
				// get system strean type using refrection
				int streamType;
				try {
					final Class audioSystemClass = Class.forName("android.media.AudioSystem");
					final Field sseField = audioSystemClass.getDeclaredField("STREAM_SYSTEM_ENFORCED");
					streamType = sseField.getInt(null);
				} catch (Exception e) {
					streamType = AudioManager.STREAM_SYSTEM;	// set appropriate according to your app policy
				}
				if (mSoundPool != null) {
					try {
						mSoundPool.release();
					} catch (Exception e) {
					}
					mSoundPool = null;
				}
				// load sutter sound from resource
				mSoundPool = new SoundPool(2, streamType, 0);
				mSoundId = mSoundPool.load(context, R.raw.camera_click, 1);
			}

			@Override
			public void run() {
				Looper.prepare();
				synchronized (mSync) {
					mHandler = new CameraHandler(this);
					mSync.notifyAll();
				}
				Looper.loop();
				synchronized (mSync) {
					mHandler = null;
					mSoundPool.release();
					mSoundPool = null;
					mSync.notifyAll();
				}
			}
		}
	}

インナークラスなのは少し手を抜くためなのでそれほど深い意味はありませんが、staticクラスにしてたりWeakReferenceにしてたりHandler(ここではCameraHandler)を生成・破棄するタイミングだったり、スレッドが終了するタイミングだったりはそれなりに理由があってこうなってます。違う方法で実現することも可能ですけどね。

これはもう、コードをじっくり見てもらって、何でこんなことしてるんやろとか考えたり、LogCatに出力されるログからそれぞれの実行されるタイミングを確認してもらうのが一番いいんでしょうね。一見は百聞に如かずです。

キーとなるのは、

  • HandlerやThreadがstaticクラス
  • MainActivity(コンテキスト)への参照をWeakReferenceにしている
  • 特定の条件でスレッドが自殺するようになっている
  • MediaScannerConnection#scanFileの処理で、アクティビティのコンテキスト(=MainActivityのインスタンス)ではなく、getApplicationContextでアプリケーションコンテキストを取得している

といったあたりかな?

エンコーダー周りのも含めてスレッドには名前をつけて生成しているので、動画保存開始時や終了時、あるいはアプリの終了時のスレッドの様子をDDMSで眺めてみると面白いかも。

スレッドのファイナライザでLog出力するようにしているけど、手持ちの機種ではこのlogが実際に出力される事はありませんでした。Javaのファイナライザを当てにしてはいけない例かな。エミュレータなら出力されるのかな?でもエミュレーターだとそもそもUVCカメラに接続でけへんから動かへんよね。

と言う事でおしまいです。

お疲れ様でした。



お花畑2