DirectShow是微软公司提供的一套在Windows平台上进行流媒体处理的开发包,与DirectX开发包一起发布。目前,DirectX最新版本为8.1。
那么,DirectShow能够做些什么呢?且看,DirectShow为多媒体流的捕捉和回放提供了强有力的支持。运用DirectShow,我们可以很方便地从支持WDM驱动模型的采集卡上捕获数据,并且进行相应的后期处理乃至存储到文件中。它广泛地支持各种媒体格式,包括Asf、Mpeg、Avi、Dv、Mp3、Wave等等,使得多媒体数据的回放变得轻而易举。另外,DirectShow还集成了DirectX其它部分(比如DirectDraw、DirectSound)的技术,直接支持DVD的播放,视频的非线性编辑,以及与数字摄像机的数据交换。更值得一提的是,DirectShow提供的是一种开放式的开发环境,我们可以根据自己的需要定制自己的组件。
DirectShow使用一种叫Filter Graph的模型来管理整个数据流的处理过程;参与数据处理的各个功能模块叫做Filter;各个Filter在Filter Graph中按一定的顺序连接成一条“流水线”协同工作。大家可以看到,按照功能来分,Filter大致分为三类:Source Filters、Transform Filters和Rendering Filters。Source Filters主要负责取得数据,数据源可以是文件、因特网、或者计算机里的采集卡、数字摄像机等,然后将数据往下传输;Transform Fitlers主要负责数据的格式转换、传输;Rendering Filtes主要负责数据的最终去向,我们可以将数据送给声卡、显卡进行多媒体的演示,也可以输出到文件进行存储。值得注意的是,三个部分并不是都只有一个Filter去完成功能。恰恰相反,每个部分往往是有几个Fitler协同工作的。比如,Transform Filters可能包含了一个Mpeg的解码Filter、以及视频色彩空间的转换Filter、音频采样频率转换Filter等等。除了系统提供的大量Filter外,我们可以定制自己的Filter,以完成我们需要的功能。下图是一条典型的Avi文件回放Filter Graph链路:
在DirectShow系统之上,我们看到的,即是我们的应用程序(Application)。应用程序要按照一定的意图建立起相应的Filter Graph,然后通过Filter Graph Manager来控制整个的数据处理过程。DirectShow能在Filter Graph运行的时候接收到各种事件,并通过消息的方式发送到我们的应用程序。这样,就实现了应用程序与DirectShow系统之间的交互。下图给出了DirectShow应用程序开发的一般过程:
以上简单介绍了DirectShow的系统结构,希望大家对这个强劲的应用框架已经有了大概的认识。如果你有兴趣,可以详细研究DirectX的帮助文档。DirectShow是一个强大的开发包;另外,它是基于COM的,因此要求程序员具有COM编程的一些基本知识。关于如何深入学习DirectShow应用结构以及开发自己的Filter,请参阅笔者的后续文章。笔者将从编程的角度,详细讲述来源于实际工作中的经验之谈。
从下面开始,我们要从程序员的角度,进一步深入探讨一下DirectShow的应用以及Filter的开发。
在这之前,笔者首先要特别提一下微软提供的一个Filter测试工具——GraphEdit,它的路径在DXSDK/bin/DXUtils/GraphEdit.exe。(如果您还没有安装DirectX SDK,请到微软的网站上去下载。)通过这个工具,我们可以很直观地看到Filter Graph的运行及处理流程,方便我们进行程序调试。(如果您手边就有电脑,还等什么,马上体验一下吧:运行GraphEdit,执行File->Render Media File…选择一个媒体文件;当Filter Graph构建成功后,按下工具栏的运行按钮;您就能看到刚才选择的媒体文件被回放出来了!看到了吧,写一个媒体播放器也就这么回事!)
接下去,我们开讲Filter的开发。
学习DirectShow Filter的开发,不外乎以下几种方法:看帮助文档、看示例代码和看SDK基类源代码。看帮助文档,应着重于总体概念上的理解;看示例代码应与基类源代码的研究同步进行,因为自己写Filter,关键的第一步是选择一个合适的Filter基类和Pin的基类。对于Filter的把握,一般认为要掌握以下三方面的内容:Filter之间Pin的连接、Filter之间的数据传输以及流媒体的随机访问(或者说流的定位)。下面就开始分别进行阐述。
所谓的Filter Pin之间的连接,实际上是Pin之间Media Type(媒体类型)的一个协商过程。连接总是从输出Pin指向输入Pin的。要想深入了解具体的连接过程,就必须认真研读SDK的基类源代码(位于DXSDK/samples/Multimedia/DirectShow/BaseClasses/amfilter.cpp,类CBasePin的Connect方法)。连接的大致过程为,枚举欲连接的输入Pin上所有的媒体类型,逐一用这些媒体类型与输出Pin进行连接,如果输出Pin也接受这种媒体类型,则Pin之间的连接宣告成功;如果所有输入Pin上枚举的媒体类型输出Pin都不支持,则枚举输出Pin上的所有媒体类型,并逐一用这些媒体类型与输入Pin进行连接。如果输入Pin接受其中的一种媒体类型,则Pin之间的连接到此也宣告成功;如果输出Pin上的所有媒体类型,输入Pin都不支持,则这两个Pin之间的连接过程宣告失败。
有一点需要注意的是,上述的输入Pin与输出Pin一般不属于同一个Filter,典型的是上一级Filter(也叫Upstream Filter)的输出Pin连向下一级Filter(也叫Downstream Filter)的输入Pin。
当Filter的Pin之间连接完成,也就是说,连接双方通过协商取得了一种大家都支持的媒体类型之后,即开始为数据传输做准备。这些准备工作中,最重要的是Pin上的内存分配器的协商,一般也是由输出Pin发起。在DirectShow Filter之间,数据是通过一个一个数据包传送的,这个数据包叫做Sample。Sample本身是一个COM对象,拥有一段内存用以装载数据,Sample就由内存分配器(Allocator)来统一管理。已成功连接的一对输出、输入Pin使用同一个内存分配器,所以数据从输出Pin传送到输入Pin上是无需内存拷贝的。而典型的数据拷贝,一般发生在Filter内部,从Filter的输入Pin上读取数据后,进行一定意图的处理,然后在Filter的输出Pin上填充数据,然后继续往下传输。下面,我们就具体阐述一下Filter之间的数据传送。
首先,大家要区分一下Filter的两种主要的数据传输模式:推模式(Push Model)和拉模式(Pull Model)。
所谓推模式,即源Filter(Source Filter)自己能够产生数据,并且一般在它的输出Pin上有独立的子线程负责将数据发送出去,常见的情况如代表WDM模型的采集卡的Live Source Filter;而所谓拉模式,即源Filter不具有把自己的数据送出去的能力,这种情况下,一般源Filter后紧跟着接一个Parser Filter或Splitter Filter,这种Filter一般在输入Pin上有个独立的子线程,负责不断地从源Filter索取数据,然后经过处理后将数据传送下去,常见的情况如文件源。推模式下,源Filter是主动的;拉模式下,源Filter是被动的。而事实上,如果将上图拉模式中的源Filter和Splitter Filter看成另一个虚拟的源Filter,则后面的Filter之间的数据传输也与推模式完全相同。
那么,数据到底是怎么通过连接着的Pin传输的呢?首先来看推模式。在源Filter后面的Filter输入Pin上,一定实现了一个IMemInputPin接口,数据正是通过上一级Filter调用这个接口的Receive方法进行传输的。值得注意的是(上面已经提到过),数据从输出Pin通过Receive方法调用传输到输入Pin上,并没有进行内存拷贝,它只是一个相当于数据到达的“通知”。再看一下拉模式。拉模式下的源Filter的输出Pin上,一定实现了一个IAsyncReader接口;其后面的Splitter Filter,就是通过调用这个接口的Request方法或者SyncRead方法来获得数据。Splitter Filter然后像推模式一样,调用下一级Filter输入Pin上的IMemInputPin接口Receive方法实现数据的往下传送。深入了解这部分内容,请认真研读SDK的基类源代码(位于DXSDK/samples/Multimedia/DirectShow/BaseClasses/source.cpp和pullpin.cpp)。
下面,我们来讲一下流的定位(Media Seeking)。在GraphEdit中,当我们成功构建了一个Filter Graph之后,我们就可以播放它。在播放中,我们可以看到进度条也在相应地前进。当然,我们也可以通过拖动进度条,实现随机访问。要做到这一点,在应用程序级别应该可以知道Filter Graph总共要播放多长时间,当前播放到什么位置等等。那么,在Filter级别,这一点是怎么实现的呢?
我们知道,若干个Filter通过Pin的相互连接组成了Filter Graph。而这个Filter Graph是由另一个COM对象Filter Graph Manager来管理的。通过Filter Graph Manager,我们就可以得到一个IMediaSeeking的接口来实现对流媒体的定位。在Filter级别,我们可以看到,Filter Graph Manager首先从最后一个Filter(Renderer Filter)开始,询问上一级Filter的输出Pin是否支持IMediaSeeking接口。如果支持,则返回这个接口;如果不支持,则继续往上一级Filter询问,直到源Filter。一般在源Filter的输出Pin上实现IMediaSeeking接口,它告诉调用者总共有多长时间的媒体内容,当前播放位置等信息。(如果是文件源,一般在Parser Filter或Splitter Filter实现这个接口。)对于Filter开发者来说,如果我们写的是源Filter,我们就要在Filter的输出Pin上实现IMediaSeeking这个接口;如果写的是中间的传输Filter,只需要在输出Pin上将用户的获得接口请求往上传递给上一级Filter的输出Pin;如果写的是Renderer Filter,需要在Filter上将用户的获得接口请求往上传递给上一级Filter的输出Pin。进一步的了解,请认真研读SDK的基类源代码(位于DXSDK/samples/Multimedia/DirectShow/BaseClasses/transfrm.cpp的类方法CTransformOutputPin::NonDelegatingQueryInterface实现和ctlutil.cpp中类CPosPassThru的实现)。
以上我们介绍了一下如何学习DirectShow Filter开发,以及一些开始写自己的Filter之前的预备知识。下一讲,笔者将根据自己开发Filter的经验,手把手教你如何写自己的Filter。
如何写自己的Filter
首先,从VC++的项目开始(请确认你已经给VC++配置好了DirectX的开发环境)。写自己的Filter,第一步是使用VC++建立一个Filter的项目。由于DirectX SDK提供了很多Filter的例子项目(位于DXSDK/samples/Multimedia/DirectShow/ Filters目录下),最简单的方法就是拷贝一个,然后再在此基础上修改。但如果你是Filter开发的初学者,笔者并不赞成这么做。
自己新建一个Filter项目也很简单。使用VC++的向导,建立一个空的”Win32 Dynamic-link Library”项目。注意,几个文件是必须有的:.def文件,定义四个导出函数;定义Filter类的.cpp文件和.h文件,并在.cpp文件中定义Filter的注册信息以及两个Filter的注册函数:DllRegisterServer和DllUnregisterServer。(注:Filter的注册信息是Filter在注册时写到注册表里的内容,格式可以参考SDK的示例代码,Filter相关的GUID务必使用GuidGen.exe产生。)接下去进行项目的设置(Project->Settings…)。此时,你可以打开一个SDK的例子项目进行对比,有些宏定义完全可以照抄,最后注意将输出文件的扩展名改为.ax。
上一讲曾经提到过,在写Filter之前,选择一个合适的Filter基类是至关重要的。为此,你必须对几个Filter的基类有相当的了解。在实际应用中,Filter的基类并不总是选择CBaseFilter的。相反,因为我们绝大部分写的都是中间的传输Filter(Transform Filter),所以基类选择CTransformFilter和CTransInPlaceFilter的居多。如果我们写的是源Filter,我们可以选择CSource作为基类;如果是Renderer Filter,可以选择CBaseRenderer或CBaseVideoRenderer等。
总之,选择好Filter的基类是很重要的。当然,选择Filter的基类也是很灵活的,没有绝对的标准。能够通过CTransformFilter实现的Filter当然也能从CBaseFilter一步一步实现。下面,笔者就从本人的实际经验出发,对Filter基类的选择提出几点建议供大家参考。
首先,你必须明确这个Filter要完成什么样的功能,即要对Filter项目进行需求分析。请尽量保持Filter实现的功能的单一性。如果必要的话,你可以将需求分解,由两个(或者更多的)功能单一的Filter去实现总的功能需求。
其次,你应该明确这个Filter大致在整个Filter Graph的位置,这个Filter的输入是什么数据,输出是什么数据,有几个输入Pin、几个输出Pin等等。你可以画出这个Filter的草图。弄清这一点十分重要,这将直接决定你使用哪种“模型”的Filter。比如,如果Filter仅有一个输入Pin和一个输出Pin,而且一进一处的媒体类型相同,则一般采用CTransInPlaceFilter作为Filter的基类;如果媒体类型不一样,则一般选择CTransformFilter作为基类。
再者,考虑一些数据传输、处理的特殊性要求。比如Filter的输入和输出的Sample并不是一一对应的,这就一般要在输入Pin上进行数据的缓存,而在输出Pin上使用专门的线程进行数据处理。这种情况下,Filter的基类选择CSource为宜(虽然这个Filter并不是源Filter)。
当Filter的基类选定了之后,Pin的基类也就相应选定了。接下去,就是Filter和Pin上的代码实现了。有一点需要注意的是,从软件设计的角度上来说,应该将你的逻辑类代码同Filter的代码分开。下面,我们一起来看一下输入Pin的实现。你需要实现基类所有的纯虚函数,比如CheckMediaType等。在CheckMediaType内,你可以对媒体类型进行检验,看是否是你期望的那种。因为大部分Filter采用的是推模式传输数据,所以在输入Pin上一般都实现了Receive方法。有的基类里面已经实现了Receive,而在Filter类上留一个纯虚函数供用户重载进行数据处理。这种情况下一般是无需重载Receive方法的,除非基类的实现不符合你的实际要求。而如果你重载了Receive方法,一般会同时重载以下三个函数EndOfStream、BeginFlush和EndFlush。我们再来看一下输出Pin的实现。一般情况下,你要实现基类所有的纯虚函数,除了CheckMediaType进行媒体类型检查外,一般还有DecideBufferSize以决定Sample使用内存的大小,GetMediaType提供支持的媒体类型。最后,我们看一下Filter类的实现。首先当然也要实现基类的所有纯虚函数。除此之外,Filter还要实现CreateInstance以提供COM的入口,实现NonDelegatingQueryInterface以暴露支持的接口。如果我们创建了自定义的输入、输出Pin,一般我们还要重载GetPinCount和GetPin两个函数。
Filter框架的实现大致就是这样。你或许还想知道怎样在Filter上实现一个自定义的接口,以及怎么实现Filter的属性页等等。限于篇幅,笔者就不展开阐述了。其实,这些问题都能在SDK的示例项目中找到答案。其他的,关于在实际编程中应该注意的一些问题,笔者整理了一下,供大家参考。
1. 锁(Lock)问题
DirectShow应用程序至少包含有两条线程:一条主线程和一条数据传输线程。既然是多线程,肯定会碰到线程同步的问题。Filter有两种锁:Filter对象锁和数据流锁。Filter对象锁用于Filter级别的如Filter状态转换、BeginFlush、EndFlush等;数据流锁用于数据处理线程内,比如Receive、EndOfStream等。如果这两种锁没有搞清楚,很容易产生程序的死锁,这一点特别需要提醒。
2. EndOfStream问题
当Filter接收到这个“消息”,意味着上一级Filter的数据都已经发送完毕。在这之后,如果Receive再有数据接收,也不应该去理睬它。如果Filter对输入Pin上的数据进行了缓存,在接收到EndOfStream后应确保所有缓存的数据都已经处理过了才能返回。
3. Media Seeking问题
一般情况下,你只需要在Filter的输出Pin上实现NonDelegatingQueryInterface方法,当用户申请得到IID_ImediaPosition接口或IID_IMediaSeeking接口时将请求往上一级Filter的输出Pin上传递。当Filter Graph进行Mediaseeking的时候,一般会调用Filter上的BeginFlush、EndFlush和NewSegment。如果你的Filter对数据进行了缓存,你就要重载它们,并做出相应的处理。如果你的Filter负责给发送出去的Sample打时间戳,那么,在Mediaseeking之后应该重新从零开始打起。
4. 关于使用专门的线程
如果你使用了专门的线程进行数据的处理和发送,你需要特别小心,不要让线程进行死循环,并且要让线程处理函数能够去时时检查线程命令。应该确保在Filter结束工作的时候,线程也能正常地结束。有时候,你把GraphEdit程序关掉,但GraphEdit进程仍在内存中,往往就是因为数据线程没有安全关闭这个原因。
5. 如何从媒体类型中获取信息
比如,你想在输入Pin连接的媒体类型中,获取视频图像的宽、高等信息,你应该在输入Pin的CompleteConnect方法中实现,而不要在SetMediaType中。
DirectX媒体对象(DirectX Media Objects,简称DMOs),是微软提供的另一种流数据处理COM组件。与DirectShow filter相比,DMO有很多相似之处。对filter原理的熟悉,将会大大帮助你对DMO的学习。另外,DMO也因其结构简单、易于创建和使用而倍受微软推崇。
DMO与filter的对比
1. DMO比filter实现的功能要少很多,这使得DMO“体积”很小;
2. DMO使用起来比filter更有灵活性。DMO的使用不需要filter graph,应用程序可以直接与DMO交互。而DMO也可以通过一个DMO wrapper filter工作于DirectShow环境;
3. DMO总是同步处理数据,不像filter有独立的数据传送线程,需要考虑多线程编程问题;
4. 与传统的编解码管理器ACM、VCM相比,用DMO开发的编解码器是基于COM的,更易于扩展。并且DMO支持多个输入和多个输出;
5. DMO不需要像filter一样分配数据传送的内存,而有DMO的使用者负责;
6. DMO是一个独立功能模块,不需要像filter一样连接成一条链路;
7. DMO不需要像filter一样将数据“推”下去,数据的输入输出都是由DMO的使用者完成的;
所有这些优点,使得DMO成为微软对于Encoder和Decoder开发的重点推荐模式。DirectX 9.0 SDK中,微软更是把DMO从DirectShow中分离出来,而对于一些transform filter,微软也推荐用DMO的方式来替换。
关于DMO的使用方式,目前大概有两种:一种是应用程序直接使用DMO,另一种就是在DirectShow filter中的应用。后者比较简单,只是使用了一个DMO wrapper filter。在DirectShow应用程序中,DMO是对用户透明的,所有使用DMO的工作均由DMO wrapper filter来完成。参见下面的代码。
// Create the DMO Wrapper filter. if (SUCCEEDED(hr)) if (SUCCEEDED(hr)) if (SUCCEEDED(hr)) |
而对于DMO的直接使用,以下几点是要特别注意的。
1. 在处理数据之前,必须为每条输入输出stream设置media type(Optional stream除外);
2. 从DMO从获取的media type未必包含format块,但是在给DMO设置media type时,务必带上这部分信息(MIDI除外);
3. 应用程序必须自己负责分配数据缓存。缓存的大小可以通过调用DMO的IMediaObject::GetInputSizeInfo或IMediaObject::GetOutputSizeInfo得到。DMO使用的数据缓存也是一个COM对象,支持ImediaBuffer接口,与DirectShow filter的Media Sample类似。
4. 一般的DMO依次调用IMediaObject::ProcessInput和IMediaObject::ProcessOutput处理数据,In-Place的DMO调用IMediaObjectInPlace::Process处理数据。两套方法不能混用。
5. 在调用ProcessOutput时,如果返回的标记是DMO_OUTPUT_DATA_BUFFERF_INCOMPLETE,说明数据的数据还没有完全取出,需要再次调用ProcessOutput。
6. 所有输入数据都已输入完成,应该调用DMO的IMediaObject::Discontinuity方法。
7. 如果你想中断数据处理流程,调用DMO的IMediaObject::Flush。
8. 区别两种不同的可丢弃stream,标记分别为DMO_OUTPUT_STREAMF_OPTIONAL和DMO_OUTPUT_STREAMF_DISCARDABLE。注意,后者是要设置media type的。