C#::DirectShow

他に書くこともないし、なんかメモ書きが出てきたのでアップ。
こないだ(id:lord_hollow:20091011:p2)紹介したデスクトップマスコットアプリで使っている、DirectShowに関する覚書。
DirectShowはストリーミングのソース(AVIファイルとかUSBカメラとか)から取得したデータをモニタやらスピーカーから出力する。
C#からDirectShowを触るには、DirectXSDKをインストール…してもダメなので、別途LGPLで開発されているDirectShowLibというラッパーライブラリを使う。以下の説明には、DirectShowLib2.0を使っている。
DirectShowを使った動画の再生は、アプリケーションから見ると、動画の読み込み〜表示までの処理は全部「フィルタグラフマネージャ」というものによって行われ、アプリケーションからはそのマネージャに対して開始、停止などの処理を行ったり、イベントを監視したりすることになる。
フィルタグラフマネージャの中にはフィルタを詰める。フィルタというのはたとえば「AVIファイルからの入力を行うフィルタ」であり、「DirectDrawによる画面表示フィルタ」であり、「DirectSoundによる音声出力フィルタ」である。フィルタにはそれぞれ独立した入出力の口があって、それは「ピン」と呼ばれている。アプリケーションでは、マネージャに必要なフィルタを登録して、そのピンを適切に接続していくことで、必要な処理を実現する。

今やりたいことは、動画を再生しながらカレントフレームの画像をSystem.Drawing.Imageとして取得することだ。これが取得できることによって、GDIで好きなように描画することができる。DirectDrawで直接フォームに描画することもできるが、そうすると動画の上に重なるキャラクタを描画できない(それができたら"Direct"Drawじゃないです))。ManagedDirectXの中に動画をテクスチャとして取得できるクラスがあるので、それが使えるならばそれを使ったほうが簡単かもしれない。けれども3Dのプログラムはともかくモデルを作れないのでフル2DなGDIで処理したいのである。

動画をGDIで描画可能な形式で取得するために、まずは動画を入力するフィルタが必要、なのだが、まずはフィルタグラフマネージャを作成しなければ始まらないのでそれを作成する。

var graphBuilder = new FilterGraph() as IGraphBuilder;

次に、このマネージャに、ソースフィルタ(入力用)を追加する。

IBaseFilter srcFilter;
graphBuilder.AddSourceFilter(path, "SourceFilter", out srcFilter);

pathは読み込むファイルのパス。これで、ファイルからストリームを読み出すようになる。それにサンプルグラバーをつなぐ。ソースの出力ピンをグラバーの入力ピンに接続すればOK。

var samplGrabber = new SampleGrabber as ISampleGrabber;
configGrabber(samplGrabber);
graphBuilder.AddFilter(sampleGrabber as IBaseFilter, "SampleGrabber");
{
  var outPin = DsFindPin.ByDirection(srcFilter, PinDirection.Output, 0);
  var inPin = DsFindPin.ByDirection((sampleGrabber as IBaseFilter), PinDirection.Input, 0);
  graphBuilder.Connect(outPin, inPin);
  Marshal.ReleaseComObject(outPin);
  marshal.ReleaseComObject(inPin);
}

最初の3行でフィルタを作って設定して登録、残りの行でソースフィルタの出力をサンプルグラバーの入力につないでいる。AddFilterの第2引数は検索用の名前なのでたぶんなんでもいい。DsFindPin.ByDirectionで得られるピンは明示的に開放する必要があるので、最後に開放している。configGrabberの中身はこんな感じ。キャプチャするデータの形式(ARGB32のビデオ)を設定してある。最後の行ではバッファのサンプリングを行うフラグをONにしている。

void configGrabber(ISampleGrabber sampleGrabber){
  AMMediaType media;
  media = new AMMediaType();
  media.majorType = MediaType.Video;
  media.subType = MediaSubType.ARGB32;
  media.formatType = FormatType.VideoInfo;
  sampleGrabber.SetMediaType(media);
  DsUtils.FreeAMMediaType(media);
  sampleGrabber.SetBufferSamples(true);
}

次。グラバーだけがあってもレンダラが存在しないとサンプルグラバーにデータが流れてこないので、レンダラを作る。普通はDirectDrawでスクリーンに表示したりするが、今回は直接画面には表示しないので、NullRendererという、どこにも表示しないレンダラを作って、サンプルグラバーの出力につなぐ。

var render = new NullRenderer() as IBaseFilter;
graphBuilder.AddFilter(render, "Renderer");
{
  var outPin = DsFindPin.ByDirection((sampleGrabber as IBaseFilter), PinDirection.Output, 0)
  var inPin =DsFindPin.ByDirection(render, PinDirection.Input, 0);
  graphBuilder.Connect(outPin, inPin);
  Marshal.ReleaseComObject(outPin);
  marshal.ReleaseComObject(inPin);
}

さっきと同じパターンで追加。
ここまで終わると、サンプルグラバーでサンプリングする画像の情報が取得できるようになるので、取得しておく。

//メディアタイプ取得
AMMediaType media = new AMMediaType();
sampGrabber.GetConnectedMediaType(media);
//サイズ情報の取得
VideoInfoHeader videoInfoHeader = (VideoInfoHeader)Marshal.PtrToStructure(media.formatPtr, typeof(VideoInfoHeader));
m_videoWidth = videoInfoHeader.BmiHeader.Width;
m_videoHeight = videoInfoHeader.BmiHeader.Height;
m_stride = m_videoWidth * (videoInfoHeader.BmiHeader.BitCount / 8);
DsUtils.FreeAMMediaType(media);

これで準備完了。いよいよ、フィルタマネージャに対して再生開始の依頼を出す。これには、IMediaControlインターフェースを使う。

 (graphBuilder as IMediaControl).Run();

これで再生が始まる。
レンダラがNullRendererなので、表示するために現在再生中のフレームの画像を取得する必要がある。再生中の状態で、sampleGrabber.GetCurrentBufferという関数を使えばいい。

public Bitmap Current{
  get {
    IntPtr p = IntPtr.Zero;
    int sz = 0;
    sampleGrabber.GetCurrentBuffer(ref sz, p);
    if (sz != 0) {
      p = Marshal.AllocCoTaskMem(sz);
      sampleGrabber.GetCurrentBuffer(ref sz, p);
      var res = new Bitmap(m_videoWidth, m_videoHeight, m_stride, 
                           System.Drawing.Imaging.PixelFormat.Format32bppArgb, p);
      Marshal.FreeCoTaskMem(p);
      res.RotateFlip(RotateFlipType.Rotate180FlipX);
      return res;
    }
    return null;
  }
}

最初にp=NULLで呼び出すことでバッファのサイズを取得、そのサイズ分のメモリを確保してpにセットし、もう一度呼び出すことでフレームがバッファに格納される。あとはBitmapクラスのコンストラクタでビットマップを作成すればよい。バッファに入っている画像は左右が反転しているので、RotateFlipをかける必要がある。Bitmapを作成した時点でバッファは不要になるので、忘れずに削除しておくこと。この関数が別スレッドに実行されることも考えて、全体をlock(this)しておくのもいい。

あとは、再生の終了を検知する必要があるので、イベントを処理する。しかし、IMediaEventのイベント処理インターフェースは、イベント確認関数でブロッキング(処理停止)してイベントが来たら次の処理、という流れなので、スレッドで処理しなければならない。

//イベント処理用オブジェクトの取得
IntPtr hEv = IntPtr.Zero;
(graphBuilder as IMediaEvent).GetEventHandle(out hEv);
var mre = new ManualResetEvent(false);
mre.SafeWaitHandle = new Microsoft.Win32.SafeHandles.SafeWaitHandle(hEv, true);

//以下の処理を別スレッドで実行する必要がある
do{
  mre.WaitOne(-1, true);	//-1はINFINITE
  lock(this){
    int hr;
    IntPtr p1, p2;
    EventCode ec;
    var ev = graphBuilder as IMediaEvent;
    for(
      hr = ev.GetEvent(out ec, out p1, out p2, 0);
      hr >= 0;
      hr = ev.GetEvent(out ec, out p1, out p2, 0){
      if(ec == EventCode.Complete){
        onComplete();	//この関数で終了処理をする
      }
      ev.FreeEventParams(ec, p1, p2);
    }
  }
} while(enabled);	//終了したくなったらこのフラグを使う

なお、DirectShowLibの各種オブジェクト(フィルタやピン)は、いらなくなったらSystem.Runtime.InteropeService.Marshal.ReleaseComObjectを使ってCOM参照カウントを減らさなければリソースリークとなるので注意。ちなみに、フィルタマネージャに登録した後のフィルタオブジェクトは使う予定がなければその場でこの操作を行っても問題ない(フィルタグラフからの参照が残る)。とはいえ、実際にはGCからも開放されるらしいので、あまり神経質になる必要はないのかもしれない。