📺

Media Foundation でビデオファイルを再生する (基礎)

2023/08/26に公開

はじめに

最近、ビデオ系アプリケーション開発から離れてしまっているので、復習を兼ねて関連技術を記事にまとめていこうかなあと思います。 (自分にとって) 未知の技術もどこかで扱っていきたいとは思いますが、まず復習かつ現行技術である Windows の Media Foundation を中心に扱っていきたいと思います。

今回は一番基本となる "Media Session を用いたビデオファイルの再生" になります。

実装したコードはこちらです。

https://github.com/aosoft/simple_mf_play/tree/2023-08-26

(main ブランチは今後も改変が入ると思います)

Media Foundation 関連の実装は C++ のライブラリとして実装していて、アプリ部分 (Window 関連) は Rust 版と C++ 版の両方があります。どちらもコマンドラインにソースとなるビデオファイルのパス指定が必要です。

当初は全て Rust でやろうとしていたのですが、 Rust でやるのは現実的ではないという結論になり、コア部分は C++ で書いてしまっています。現状は Rust を使っている意味はほぼない状態です。

https://github.com/aosoft/simple_mf_play/tree/2023-08-26/cpp

ここから CMake で開くと C++ アプリケーションのプロジェクトになります。

Media Foundation とは

https://learn.microsoft.com/ja-jp/windows/win32/medfound/microsoft-media-foundation-sdk

Media Foundation は Windows のメディア用フレームワークで DirectShow の後継のものです。世代的には Windows Vista からとなっています。

DirectShow は現在でも使用することはできますが、 Microsoft の Codec 実装などは追加されておらず、 GPU 対応も基本的に Direct3D 9 ベースまでとなっており、現在は通常は使用しない API となっています。 DirectShow の魅力は様々な Codec, Filter 類の存在ですが、 x86 版しか存在しない場合が多く、 x64 や ARM 環境で DirectShow を使う理由がほとんどない事になります。

ビデオ再生に用いるアーキテクチャ

"Media Foundation でビデオ再生をする" と一口で言ってしまうと様々な方法があり、またどこまで独自の実装をするのか、という話もあるのですが、今回は Media Foundation の一番基礎的な構成で行いたいと思います。

  • Media Session を用いる (プッシュモデル)
    • タイマー制御は Media Session 任せ
  • レンダラーは自動割り当てに任せる
    • 映像は Window を指定し、その Window に描画してもらう
    • 音声は既定の動作任せ

プッシュモデルとプルモデル

https://learn.microsoft.com/ja-jp/windows/win32/medfound/overview-of-the-media-foundation-architecture

Media Foundation でのメディアへのアクセスは "プッシュモデル" と "プルモデル" の 2 つをサポートしています。 DirectShow でも両方をサポートしていますが実質はプッシュモデルしか使えません (正確には一部のフィルターはプルモデルで動作しますが、パイプライン全体で見るとプッシュモデル前提でのアクセスになる) 。

プッシュモデルはパイプライン全体が非同期で動作し、データの流れはイベントベースで通知が行われるものです。メディアの時間軸に対する制御をパイプラインが行います。データの取得をアプリケーション側が主体的に行うことはできません。パイプラインが勝手にデータを送り付けてくる (プッシュしてくる) ものです。

一方、プルモデルはアプリケーションが主体的にデータを取得する (プルする) もので、 Media Foundation に対するデータ取得、出力は同期的 (呼び出しに対する処理が完了できるまで返ってこない) になります。

一般的に

  • リアルタイムな再生をする場合はプッシュモデル
  • タイマー制御を独自にしたり、ファイルに書き出す場合はプルモデル

が適しています (がプッシュモデルでファイル出力をする場合もあるのでケースバイケース) 。

今回使用する Media Session はプルモデルでの動作になります。

Media Session の構成

Media Session によるビデオ再生は大きくは次の要素で構成されます。

  • Media Session (IMFMediaSession) はパイプライン全体を管理するものです
    • Topology と状態変更を通知する Event を管理します
  • Topology (IMFTopology) はパイプライン内のノード接続状態を管理するものです
  • TopologyNode (IMFTopologyNode) はパイプライン構成要素となるノードです
    • TopologyNode を Topology に登録し、接続可能なノード間を適宜接続します
  • Media Source (IMFMediaSource) はメディアソースを表します
    • 通常は Source Resolver (IMFSourceResolver) を用いてソースを開きます
    • Media Source の中から Presentation Descriptor (IMFPresentationDescriptor), Stream Descriptor (IMFStreamDescriptor) を取得し、適宜 Source Node に設定します
  • Activation Object (IMFActivate) は目的のインスタンス生成をする (アクティブ化する) ものです
    • ファクトリーのようなもの
    • 実体の生成を必要なタイミングまで遅延させるために使用します
    • ここでは Media Sink (レンダラー) の生成のために使用します

Windows SDK には "TopoEdit" という DirectShow でいうところの GraphEdit のようなツールがついています。 (C:\Program Files (x86)\Windows Kits\10\bin(Version)(arch)\topoedit.exe)

このツールを起動し "File - Render File" で任意のソースを指定すると Topology の組み方が GUI で確認できるので、まずこちらのツールを試す事をお勧めします。

緑の矩形が TopologyNode 、その端にある黒い矩形が TopologyNode 間で接続された MediaType (ストリームの各種フォーマット) などになり、クリックするとそれらの情報が表示されます。

ソースの開き方

ビデオファイルは様々な形式がありますが、その形式が開ける Reader をあらかじめ特定するのが難しい場合があります (汎用的な Media Player など) 。そのような場合、 Source Resolver を利用すると指定のファイルから適切な Reader を取得してくれます。

Media Session でビデオ再生をする

  • Media Session (IMFMediaSession)
  • Topology (IMFTopology)

以下、サンプルコードでは次のマクロ、エイリアスを使用します。

template <class Intf>
using com_ptr = Microsoft::WRL::ComPtr<Intf>;

#define CHECK_HR(hr)         \
    {                        \
        HRESULT hrtmp = hr;  \
        if (FAILED(hrtmp)) { \
            return hrtmp;    \
        }                    \
    }

Media Foundation の初期化

Media Foundation を使えるようにするにはアプリケーションの初期化時に MFStartup の呼び出しをする必要があります。 API は COM ベースですが CoInitialize は必須ではなさそうです (が作法としてはしておくべきかも) 。

MFStartup(MF_VERSION, 0);

終了時は MFShutdown を呼びます。

Media Session の準備とソースを開く

Media Session を生成します。

com_ptr<IMFMediaSession> _session;
CHECK_HR(MFCreateMediaSession(nullptr, &_session));

Source Resolver を用いてソースを開きます。

com_ptr<IMFMediaSource> _source;

com_ptr<IUnknown> source;
com_ptr<IMFSourceResolver> source_resolver;

CHECK_HR(MFCreateSourceResolver(&source_resolver));
CHECK_HR(source_resolver->CreateObjectFromURL(
    url, MF_RESOLUTION_MEDIASOURCE, nullptr, &object_type, &source));
CHECK_HR(source.As(&_source));

Topology の組み立て

次に Topology の準備をします。

CHECK_HR(MFCreateTopology(&topology));
CHECK_HR(_source->CreatePresentationDescriptor(&source_pd));

Topology と Presentation Descriptor を作成します。 Presentation Descriptor はソースから作成します (ソースの情報なので) 。

ソースの情報に基づいて Topology の構成をします。

CHECK_HR(source_pd->GetStreamDescriptorCount(&source_streams));

for (DWORD i = 0; i < source_streams; i++) {
    com_ptr<IMFStreamDescriptor> source_sd;
    com_ptr<IMFTopologyNode> source_node;
    com_ptr<IMFTopologyNode> output_node;
    BOOL selected = FALSE;

    CHECK_HR(source_pd->GetStreamDescriptorByIndex(i, &selected, &source_sd));

Presentation Descriptor から Stream Descriptor を取得します。Stream Descriptor は選択されているもののみを使用できるので、選択済のもののみを対象とします。選択状態は Presentation Descriptor で変更できますが、ここではデフォルトのものを使用します。

    if (selected) {
        CHECK_HR(MFCreateTopologyNode(MF_TOPOLOGY_SOURCESTREAM_NODE, &source_node));
        CHECK_HR(source_node->SetUnknown(MF_TOPONODE_SOURCE, _source.Get()));
        CHECK_HR(source_node->SetUnknown(MF_TOPONODE_PRESENTATION_DESCRIPTOR, source_pd.Get()));
        CHECK_HR(source_node->SetUnknown(MF_TOPONODE_STREAM_DESCRIPTOR, source_sd.Get()));

Source Node を準備します。ソース用の Topology Node を作成し、 Source 、 Presentation Descriptor と対応する Stream Descriptor を設定します。

        com_ptr<IMFMediaTypeHandler> handler;
        com_ptr<IMFActivate> renderer_activate;
        GUID major_type;

        CHECK_HR(source_sd->GetMediaTypeHandler(&handler));
        CHECK_HR(handler->GetMajorType(&major_type));
        CHECK_HR(MFCreateTopologyNode(MF_TOPOLOGY_OUTPUT_NODE, &output_node));
        if (major_type == MFMediaType_Audio) {
            CHECK_HR(MFCreateAudioRendererActivate(&renderer_activate));
        } else if (major_type == MFMediaType_Video) {
            CHECK_HR(MFCreateVideoRendererActivate(hwnd_video, &renderer_activate));
        } else {
            return E_FAIL;
        }
        CHECK_HR(output_node->SetObject(renderer_activate.Get()));

Output Node を準備します。出力用の Topology Node を作成し、 Media Sink を用意します。

Stream Descriptor から Video か Audio かの情報を取得し、対応する Media Sink を用意します。 Media Sink は直接生成するのではなく、 Activation Object を使用します。

        CHECK_HR(topology->AddNode(source_node.Get()));
        CHECK_HR(topology->AddNode(output_node.Get()));
        CHECK_HR(source_node->ConnectOutput(0, output_node.Get(), 0));
    }
}

最後に Topology に準備した Topology Node を登録し、ノード間を接続します。これをストリーム数だけ繰り返します。

CHECK_HR(_session->SetTopology(0, topology.Get()));

全てのストリームに対して処理を終えたら Media Session に Topology を設定して完了です。

イベントの対応

プッシュモデルは非同期で処理が行われるため、全ての状態変更はイベント通知で行われます。

イベント通知は IMFAsyncCallback インターフェースを実装したクラスを Media Session に登録することで受ける事ができるようになります。

CHECK_HR(_session->BeginGetEvent(_callback.get(), nullptr));

BeginGetEvent で Callback を登録してイベント受信を開始します。
Microsoft のサンプルなどでは定義しているプレイヤークラス自体に IMFAsyncCallback を実装していますが、ここでは別のクラス (async_callback クラス) を定義し、そのインスタンスを指定しています。

イベント処理本体は次のように実装しています。

HRESULT mfplayer_impl::on_event_callback(com_ptr<IMFMediaEvent> event)
{
    MediaEventType event_type = MEUnknown;
    HRESULT status;
    MF_TOPOSTATUS topo_status = MF_TOPOSTATUS_INVALID;

    CHECK_HR(event->GetType(&event_type));
    CHECK_HR(event->GetStatus(&status));
    if (SUCCEEDED(status)) {
        switch (event_type) {
        case MESessionTopologyStatus:
            CHECK_HR(event->GetUINT32(
                MF_EVENT_TOPOLOGY_STATUS, reinterpret_cast<UINT32*>(&topo_status)));
            switch (topo_status) {
            case MF_TOPOSTATUS_READY:
                MFGetService(
                    _session.Get(), MR_VIDEO_RENDER_SERVICE, IID_PPV_ARGS(&_video_display));
                CHECK_HR(start_playback());
                break;
            default:
                break;
            }
        case MEEndOfPresentation:
            _state = player_state::stopped;
            break;
        }
    }
    return S_OK;
}

ここでの要点ですが

  • GetType でイベントタイプを取得します
  • イベントタイプが MESessionTopologyStatus かつ Topology のステータスが MF_TOPOSTATUS_READY の場合は再生準備完了なので次の事をします
    • IMFVideoDisplayControl (ウィンドウのリサイズ追従などで使用) の取得
    • 再生開始
  • イベントタイプが MEEndOfPresentation の場合は再生終了と判断し、アプリ内のステータスを更新しておく

イベントの処理

Media Foundation の内部はマルチスレッドで動作しているので、イベントもワーカースレッドから通知が発生します。今回はプレイヤーとして実装しているためメインスレッドで処理できた方が都合がよいため、 C# WPF でいう Dispatcher.BeginInvoke と同じようなことをしてメインスレッドにイベントを転送しています (上記の on_event_callback はメインスレッドで実行されるように実装しました) 。

Media Foundation と直接関係がないので詳細の説明は省きますが、当該処理は window_message_queue クラスで実装していますのでそちらを参照してください (weak_ptr での管理など、まあまあ複雑になってしまっています) 。

ウィンドウの状態変更への対応

ビデオを描画しているウィンドウの状態変更があった場合、それをレンダラーに通知する必要があります。例えばウィンドウの大きさが変わったらそれに合わせてレンダラーのサイズも変更する必要があります。

レンダラーの調整は IMFVideoDisplayControl インターフェースから行います。

case MF_TOPOSTATUS_READY:
    MFGetService(
        _session.Get(), MR_VIDEO_RENDER_SERVICE, IID_PPV_ARGS(&_video_display));

レンダラーは IMFActivate を使用しているので Topology の準備が完了するまで実体が存在していません。よって先ほどのイベントハンドラーの部分で MESessionTopologyStatus (MF_TOPOSTATUS_READY) の通知があったタイミングで Media Session から取得します。

return _video_display != nullptr ? _video_display->RepaintVideo() : S_OK;

WM_PAINT に対しては RepaintVideo を

RECT rect = { 0, 0, width, height };
return _video_display->SetVideoPosition(nullptr, &rect);

WM_SIZE に対しては SetVideoPosition をそれぞれ呼び出します。

おわりに

今回は Media Foundation におけるビデオ再生の基本で、単純に動画再生をするだけの場合は概ねこのような形になります。
ただ今回は細かいところはほとんどシステムにお任せにしているので、例えば "ゲーム中のビデオ再生のために任意のテクスチャに対して描画したい" などは今回のコードでは対応できません。その辺りは追々記事にまとめていきたいと思います。

Discussion