以文本方式查看主题 - 温馨小筑 (http://www.learnsky.com/bbs/index.asp) -- 电脑编程 (http://www.learnsky.com/bbs/list.asp?boardid=6) ---- DirectShow (http://www.learnsky.com/bbs/dispbbs.asp?boardid=6&id=868) |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:46:00 -- DirectShow 最近一段时间,在编写DirectShow应用程序时常常遇到一些问题,原因是对DirectShow技术没有较全面地掌握,对各个接口间的关系以及filter与filter之间连接的内部过程等都只是一知半解,除了再仔细地看看DirectShow的基类库源文件之外,觉得也很有必要从头到尾看一遍DirectShow的MSDN文档。在看时顺便有选择地翻译出来,一来以便以后再看时可以轻松点,二来也敦促自己不能不求甚解早早看看了事。在翻译的过程中也加了一些自己的补充,因为觉得某些MSDN章节实在是过于简单还有些模棱两可。 1. DirectShow介绍 *DirectShow开发需要什么样的编译器?
在没有包含Initguid.h的地方,DEFINE_GUID宏创建外部引用来使用GUID值,在包含Initguid.h的地方,DEFINE_GUID重定义DEFINE_GUID宏以产生GUID的定义。 如是没有在任何地方添加Initguid.h,你会得到一个链接错误:"unresolved external symbol." ,如果同样的GUID包含Initguid.h两次,会得到编译错误"redefinition; multiple initialization."要解决这些问题,请确认Initguid.h只包含一次。同样的,不要包含Initguid.h到预编译头文件中去,因为预编译头文件会被每个源文件包含。 [此贴子已经被作者于2005-11-26 2:49:14编辑过]
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:46:00 -- 桃花坞里桃花庵,桃花庵下桃花仙。 桃花仙人种桃树,又摘桃花换酒钱。 酒醒只在花前坐,酒醉还来花下眠。 半醉半醒日复日,花落花开年复年。 但愿老死花酒间,不愿鞠躬车马前。 车尘马足显者事,酒盏花枝隐士缘。 若将显者比隐士,一在平地一在天。 若将花酒比车马,彼何碌碌我何闲。 别人笑我太疯癫,我笑他人看不穿。 不见五陵豪杰墓,无花无酒锄作田。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:48:00 -- 开始DirectShow旅程 2. 开始DirectShow旅程
下面是这个例子的完整代码:
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:49:00 -- 3. 关于DirectShow
如图,DirectShow将应用程序与众多复杂的设备隔离开来,通信和控制这些设备均出DirectShow的filter来完成。DirectShow同样为某种文件格式提供与之对应的编解码器。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:50:00 -- 3.2. Filter Graph和它的组件
3.2.2. 关于Filter Graph Manager
AM_MEDIA_TYPE结构体还包含了一些任选项,用来提供附加的信息,filter不需要这些信息: *ISampleSize,如果这个字段非零,表示这是每个sample的尺寸,如果是零,则表示sample的尺寸会改变。 *bFixdSizeSamples,如果这个布尔类型的标记是TRUE,表示ISampleSize有效,否则,你可以忽略ISampleSize。 *bTemporalCompression,如果这个布尔类型的标记是FALSE,表示所有帧都是关键帧。 3.2.4. 关于媒体样本(Media Sample)和分配器(Allocator) WDM流设备 较新的硬解码设备和采集卡都遵照WDM规范。这些设备和比VFW设备更强大的功能,以及可以应用于多种系统(winxp,winNT,win2000,win98/me)。WDM视频采集卡支持许多VFW所没有的功能,包括枚举采集的格式、编程控制视频参数(如对比度、亮度)、编程选择输入端和电视调谐支持。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:51:00 -- 3.3. 构建Filter Graph 3.3.2 Grap构建概述
Filter Graph Manager支持下列Graph构建方法: *IFilterGraph::ConnectDirect,在两个pin之间进行直接连接,如果连接失败,则返回失败 *IFilterGraph::Connect,连接两个Pin,如果可能的话,直接连接它们,否则,在中间加入其它的filter来完成连接。 *IGraphBuilder::Render,从某个输出Pin处开始完成余下的graph构建。该方法会自动在输出pin后面添加必须的filter,直到renderer filter为止。 *IGraphBuilder::RenderFile,构建一个完整的文件回放graph。 *IGraphBuilder::AddFilter,将一个filter添加到graph中。它不连接filter,并且在调用此方法前,filter必须已经被创建。创建filter可以是用CoCreateInstance方法或使用Filter Mapper或系统设备枚举器(System Device Enumerator)。 这些方法提供了三种构建graph的途径: 1.filter graph manager构建整个graph 2.filter graph manager构建部分graph 3.应用程序构建整个graph Filter Graph Manager构建整个graph 如果你仅仅是想回放一个已知格式的文件,如AVI、MPEG、WAV或MP3,使用RenderFile方法。 RenderFile方法首先寻找注册在系统中能分析源文件的filter,它使用协议名(如http://),文件扩展名或文件的头几个字节来决定选择哪一个源filter。 Filter Graph Manager使用一个迭代过程来完成余下的graph构建。在这个迭代过程中,它逐个列出filter的输出pin上支持的媒体类型,并搜索哪个已注册的filter的输入Pin接受该媒体类型。它使用一系列的规则来缩小filter的范围并排定优先顺序: *filter类别(category)标识的filter的一般功能 *媒体类型描述filter能在接受或能输出哪种数据类型 *merit值决定filter被尝试的次序。如果两个filter具有相同的filter类别并且同时支持相同的输入类型,Filter Graph Manager选择merit值大的那一个。一些filter故意给出一个小merit值是因为它是为特殊用途设计的,仅能由应用程序来将其添加到graph。 Filter Graph Manager使用Filter Mapper对象来搜索已注册的filter。 每个filter被添加时,filter graph manager试着将其与前一个filter的输出pin连接。它们协商决定他们是否能连接,如果能,哪一种媒体类型被用来连接。如果新filter不能连接,filter graph manager丢弃它并尝试别一个,这个过程一直继续到每个流都被render为止。 Filter Graph Manager构建部分graph 如果不仅仅是播放一个文件,那么你的应用程序就必须做一些graph的构建工作。比如,一个视频采集应用程序必须先选择一个source filter并将其添加到graph中去。如果你需要将数据写入到一个AVI文件中,你必须添加一个AVI Mux和File Write filter。不过,也经常有可能让filter graph manager来完成整个graph,比如,你可以通过Render方法来render一个pin进行预览。 应用程序构建整个graph 在某些场合,你的应用程序需要添加和连接每个filter来构建graph。在这种情况下,你很可能明确地知道哪些filter需要加到graph中去。使用这种方式,应用程序通过调用AddFilter方法添加每个filter,然后枚举filter上的pin,调用Connect或ConnectDirect来连接它们。 3.3.3. 智能连接 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:51:00 -- 3.4. Filter Graph中的数据流 每一个allocator都创建一个media sample池,并为每个sample分配缓冲区。一旦一个filter需要一个缓冲区来填充数据,它就调用IMemAllocator::GetBuffer方法来请求一个sample。只要allocator有一个sample还没有被任何filter使用,GetBuffer方法就立即返回一个sample的指针。如果allocator所有的sample已经被用完,这个方法就阻塞在那里,直到有一个sample变成可用的了。GetBuffer返回一个sample后,filter就将数据写入到sample的缓冲区中去,并在sample上设置适当的标记(如时间戳),然后将它递交到下一个filter去。 上游的filter可能在renderer之前运行,这就意味着,上游的filter填充缓冲的速度可能快于renderer销毁它们。但是尽管如此,samples也并无必要更早地被render,因为renderer将一直保存它们直到适当的时机去render,并且,上游filter也不会意外地将这些samples的缓冲覆盖掉,因为GetSample方法只会返回那些没有被使用的sample。上游filter可以提前使用的sample的数量取决于allocator分配池中的sample的数量。
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:52:00 -- 3.5 DirectShow中的事件通告 3.5.2 从队列中取事件
要重置filter graph manager默认的事件处理过程,调用IMediaEvent::CancelDefaultHandling方法,用事件号做参数。你可以通过调用IMediaEvent::RestoreDefaultHandling方法来恢复某个事件的处理过程。如果filter graph对某个事件号没有默认处理过程,则调用上面两个方法不产生任何影响。 3.5.3 当事件发生时
消息是一个普通的windows消息,并且独立于DirectShow消息通告队列被发送。使用这种方法的好处是大部分应用程序拥有一个消息循环,因此,要知道DirectShow事件何时发生便无需做额外的工作了。
因为事件通告与消息循环均为异步进行的,因此在应用程序响应事件时队列中可以会有多个事件。而当事件变为非法时,它们会从队列中被清除掉。所以在你的事件处理代码中,调用GetEvent直至返回一个表示队列已空的失败代号。
因为Filter Graph会在适当的时候自动重设事件,因此你的应用程序应当不去作重设工作。同时,当你释放filter graph时,filter graph会关闭事件句柄,因此在这之后你就不能再使用事件句柄了。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:53:00 -- 3.6. DirectShow中的时间和时钟 这一节主要概述DirectShow体系中时间和时钟。 3.6.1. 参考时钟 Filter Graph Manager的一个功能,能够以同一个时钟来同步所有在graph中的filter,称作参考时钟(reference clock)。 任何暴露了IReferenceClock接口的对象都能够作为一个参考时钟来使用。参考时钟可以由一个DirectShow filter来提供,例如可以直接使用硬件时钟的audio renderer。另外,Filter Graph Manager也能使用系统时间来作参考时钟。 名义上,一个参考时钟以千万分之一秒的精度来度量时间,但是实际上的精度不会这么高。要取得参考时钟的当前时间,调用IReferenceClock::GetTime方法。由于时钟的基准时间,即时钟开始时的时间计数,是依赖于具体的实现的,因此GetTime的返回值不反映绝对时间,只反映相对于graph开始时的相对时间。 虽然参考时钟的精度是变化的,但是GetTime的返回值却保证是单调递增的,换句话说,也就是参考时钟的时间是不会回退的。如果参考时钟的时间是由硬件源产生的,而硬件时钟回退了(比如,有一个调节器调节了时钟),GetTime依然返回最晚的那个时间只到硬件时钟追上它。要知道更多的内容可以参考CBaseReferenceClock类(http://msdn.microsoft.com/library/en-us/directshow/htm/cbasereferenceclockclass.asp)。 默认参考时钟 当Graph运行时,Filter Graph Manager会自动选择参考时钟,选择参考时钟的规则如下: *如果应用程序指定了一个时钟,则使用这个时钟; *如果Graph包含了一个支持IReferenceClock的活动源filter(即推模式源filter),则使用这个filter; *如果Graph未包含任何支持IReferenceClock的推模式源filter,使用任何一个支持IReferenceClock接口的filter,选择的次序是从Renderer filter开始依次向上。已连接的filter优先于未连接的filter被选。(如果这个graph会render一个音频流,则这个规则通常就会选择audio renderer filter来作为参考时钟) *如果没有filter支持合适的时钟,则使用系统参考时钟。 设置参考时钟 应用程序可以在Filter Graph Manager上调用IMediaFilter::SetSyncSource方法来选择时钟,只有在由于你有一个特殊原因想要选择自己的时钟时才需要这么做。 想要让Filter Graph Manager不使用任何参考时钟,可以调用SetSyncSource,参数为NULL。比如,你需要尽可能快地来处理sample时,就可以这么做。要恢复黑认的参考时钟,在Filter Graph Manager上调用IFilterGraph::SetDefaultSyncSource方法。 当参考时钟发生变化时,Filter Graph Manager会通知每一个filter调用它的IMediaFilter::SetSyncSource方法,应用程序无需调用filter的这个方法。 3.6.2. 时钟时间 DirectShow定义了两种相关时间:参考时间(reference time)和流时间(stream time) *参考时间是一个绝对时间,由参考时钟返回 *流时间是一个相对于graph最后开始时的相对时间 ·当graph处于运行态时,流时间等于参考时间减去起始时间 ·当graph处于暂停态时,流时间停留在暂停的那一刻 ·在重新定位后,流时间被重设为0 ·当graph处于停止态时,流时间无意义 如果一个媒体样本有一个时间戳t,表示这个在流时间为t时被render,正因为这个原因,因此流时间也被叫做呈现时间(presentation time)。 当应用程序调用IMediaControl::Run运行graph时,Filter Graph Manager调用每个filter的IMediaFilter::Run。为了补偿消耗在运行每个filter的时间总和,Filter Graph Manager会略微晚一点来定义起始时间。 3.6.3. 时间戳 时间戳定义了媒体样本的起始和结束时间。时间戳有时被称作呈现时间(presentation time)。在阅读余下的文章时,一个必须记住的要点是并非所有的媒体格式都以相同的方式来使用时间戳。举个例子,并不是所有MPEG样本都被打上了时间戳,在MPEG Filter Graph中,时间戳在被解码前并非应用在每个帧上。 当一个renderer filter接收到一个样本时,它以时间戳为基准来确定render时间。如果样本来晚了,或者这个样本没有时间戳,那个filter就立刻render它,否则,filter就等在那直到合适的时机。(通过IReferenceClock::AdviseTime方法来等待样本的render时间) 源filter和语法解析filte使用下列原则,在它们处理的样本上设置合适的时间戳: *文件回放:第一个样本被打上起始时间戳,为0,后面的时间戳由样本长度和回放速率来决定,这些都由文件格式来决定。分析文件的filter负责计算出合适的时间戳。例子见(http://msdn.microsoft.com/library/default.asp?url=/library/en-us/directshow/htm/avisplitterfilter.asp) *音视频采集:每个样本都被打上一个起始时间戳,这个时间戳与当它被捕获时的Stream time相同。应注意以下几点: ·从预览pin(Preview Pin)出来的样本没有时间戳。因为图像处理的延时,一个打上采集时间的视频帧总是会迟一点到达视频renderer。这会导致在进行质量控制时,renderer会丢弃部分视频帧。关于质量控制,参见(http://msdn.microsoft.com/library/en-us/directshow/htm/qualitycontrolmanagement.asp) ·音频采集:音频采集filter使用它自己的缓冲集,而并非使用音频驱动程序的。音频驱动以固定的时间间隔来填充采集filter的缓冲。这个时间间隔由驱动决定,通常不超过10毫秒。在音频样本上的时间戳反映的是驱动填充采集filter时的时间,因此会有偏差,尤其是当应用程序使用一个很小的缓冲区时。不过,媒体时间可以精确地反映缓冲区中音频样本的数量。 *混合filter(Mux filter):依赖于输出格式,一个mux filter可能需要由它来产生时间戳,也可能不需要。举个例子,AVI文件格式使用固定的帧率而没有时间戳,因此AVI Mux filter假设那些样本在近似正确的时间内到达。如果样本没有在合适的时间间隔内到达,AVI Mux filter会插入一个长度为0的空样本,来表示一个丢失的帧。在文件回放时,新的时间戳在运行时如前面所述地那样产生。 要在一个样本上设置一个时间戳,调用IMediaSample::SetTime方法。 此外,filter还可以为样本指定一个媒体时间(media time)。在视频流中,media time表示视频帧的数量。在音频流中,media time表示包中的样本数量,比如,如果每个包包含以44.1KHz的采样率采集的一秒钟的音频,那么第一个包具有一个为0的媒体起始时间以及为44100的媒体终止时间。在一个可以定位的流中,媒体时间总是相对于流的起始时间,比如,假设你在一个15帧/秒的视频流上定位到2秒这个位置,那么定位后的每一个媒体样本的时间媒为0,但是它的媒体时间为30. Renderer和Mux filter能使用媒体时间通过检查是否有缺口来确定帧或样本是否被丢弃了。但是,filter不是一定要设定媒体时间。要设置媒体时间,调用IMediaSample::SetMediaTime方法。 3.6.4 实时源(Live Source) 实时源,也被叫做推模式源(push source),实时地接收数据,比如视频采集和网络广播。通常情况下,一个实时源不能控制数据到达的速率。 一个filter被认为是实时源需要具有以下几点: * 调用IAMFilterMiscFlags::GetMiscFlags方法时返回AM_FILTER_MISC_FLAGS_IS_SOURCE标记,并且至少有一个输出pin暴露IAMPushSource接口。 * filter暴露IKsPropertySet接口,并具有一个capture pin(PIN_CATEGORY_CAPTURE)。 延时(latency) 一个filter的延时是这个filter处理一个样本所需的时间总和。在实时源中,延时取决于保存样本的缓冲区大小。举个例子,假设graph有一个具有33ms延时的视频源和一个具有500ms延时的音频源,那么每个到达视频renderer的视频帧要比与之匹配的音频样本到达音频renderer早470ms,除非graph对这个差别进行补偿,否则音视频将会不同步。 实时源可以通过IAMPushSource接口来进行同步。Filter Graph Manager并做同步工作除非应用程序通过调用IAMGraphStreams::SyncUsingStreamOffset方法来激活它。如果同步被激活,Filter Graph Manager通过IAMPushSource来查询每一个source filter,如果filter支持IAMPushSource,那么Filter Graph Manager调用IAMLatency::GetLatency来得到filter预期的延时(IAMPushSource继承自IAMLatency)。通过组合的延时值,filter graph manager决定graph中最大的预期延时,然后调用IAMPushSource::SetStreamOffset来给每一个source filter一个流偏移,以后filter会在产生时间戳时加上这个偏移。 这个方法主要是为了实现实时预览,但是,注意实时采集设备(比如摄像头)的preview pin上是没有时间戳的,因此,要在一个实时采集设备上使用这种方法,你必须在capture pin上进行视频预览。 通常,IAMPushSource接口被VFW Capture filter和音频采集filter(Audio capture filter)支持。 速率匹配(Rate Matching) 如果renderer filter和source filter使用不同的参考时钟,那么就会有问题,renderer可能比source要快,这就导致了数据的缺口,或则renderer比source慢,就会导致数据拥堵而样本丢弃。通常一个实时源无法控制速率,因此要求renderer来与source进行速率匹配。 通常,只有audio renderer实现速率匹配,因为声音回放的频率比视频更重要。要实现速率匹配,audio renderer必须排除以下几点: *如果graph没有使用一个参考时钟,那么audio renderer不会去进行速率匹配(如果graph没有参考时钟,那么样本总是在到达时就被立刻render)。 *另外,如果graph中有一个参考时钟,audio renderer检测是否有一个实时源在上游,如果没有,audio renderer不进行速率匹配。 *如果有一个实时源在上游,并且这个实时源在它的输出Pin上暴露IAMPushSource接口,audio renderer调用IAMPushSource::GetPushSourceFlags,并寻找以下标记: ·AM_PUSHSOURCECAPS_INTERNAL_RM,这个标记表示这个实时源拥有自己的速率匹配机制,因此audio renderer不进行速率匹配。 ·AM_PUSHSOURCECAPS_NOT_LIVE,这个标记表示source filter并不是一个真正的实时源,即使它暴露了IAMPushSource接口,因此,audio renderer不进行速率匹配。 ·AM_PUSHSOURCECAPS_PRIVATE_CLOCK,这个标记表示source filter使用一个私有的时钟来产生时间戳。在这种情况下,audio renderer速率匹配与时间戳会有冲突。(如果样本没有时间戳,那么renderer忽略这个标记。 *如果GetPushSourceFlags返回没有标记(0),audio renderer的行为依赖于graph时钟和样本是否拥有时间戳: ·如果audio renderer不是graph参考时钟,并且样本拥有时间戳,那么audio renderer速率匹配与时间戳会有冲突 ·如果样本没有时间戳,audio renderer尝试与输入的音频数据的速率进行匹配。 ·如果audio renderer是graph参考时钟,它与输入的数据速率进行匹配。 最后一种情况的原因如下:如果audio renderer是参考时钟,并且source filter使用同样的时钟来产生时间戳,那么audio renderer不会与这个时间戳进行速率匹配,因为如果它这样做了,导致的结果是,它等于在尝试与自己进行速率匹配,这将导致时钟偏差。因此,在这种情况下,renderer与输入的音频数据速率进行匹配。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:53:00 -- 3.7. Graph动态重建(Dynamic Graph Building)
步骤1. 阻塞数据流 通过调用Pin A上的IPinFlowControl::Block方法来阻塞数据流。这个方法既可以被同步调用,也可以被异步调用。要异步调用这个方法,需要创建一个win32事件对象,并将事件句柄传给Block,方法会立即返回,然后使用WaitForSingleObject或其它函数来等待事件的触发。当阻塞工作完成时,pin会触发这个事件。如:
如果是同步调用Block,那么只需将传入的hEvent参数设为NULL,此时这个方法会一直阻塞到阻塞工作完成为止。如果pin还没有准备好deliver一个新的sample,那么就会一直阻塞。而如果filter处于就绪状态,这可能会花费任意长的时间,因此,不要在应用程序的主线程中使用同步调用,以免发生死锁,开一个工作线程来使用同步调用,或者干脆就使用异步调用。 步骤2. 重连pin
如果Reconnect还不够用来应付我们的要求,那么你可以使用Reconfigure方法,它调用一个由应用程序定义的回调函数来重连这些pin。要调用这个方法,需要在你的应用程序中实现IGraphConfigCallback接口。 步骤3. 取消数据流的阻塞 注意:如果动态重连是由一个filter来执行的,那么你需要知道一点线程方面的问题。如果filter graph manager尝试去停止filter,它可能会死锁,因为graph等待filter停止,而与此同时,filter有可能在等待数据在graph中完成push。要防止这个可能存在的死锁问题,如前所述可以用事件机制来处理。 3.7.2. filter链(filter chains)
IFilterChain::StartChain 开启一个链 没有特殊的方法来添加一个链,要添加链,通过调用IFilterGraph::AddFilter方法来插入新的filter,然后调用IGraphBuilder::Connect,IGraphBuilder::Render或类似的方法来连接它们。
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:54:00 -- 3.8. 插件发布者(Plug-in Distributors) Plug-in Distributors(PIDs)是扩展filter graph manager的一种方法。一个PID是filter graph manager在运行时聚合的一个COM对象。应用程序通过filter graph manager来进入PID。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:55:00 -- 奇妙”的Merit(玩死Media Player) 运行GraphEdit,插入Filter,我们可以看到:每个Filter的信息一般包括Displayname、Filename、Merit、各个Pin以及Pin支持的Mediatype,还有Version等。我们今天就来看一看这个Merit(其他的一些Filter信息大家从它的名字上就可以猜到它的意义)。 现在我们知道了智能连接是怎么回事。DirectShow的这个机制,很“聪明”,可以方便地使用第三方(非Microsoft公司)开发的Filter。但是,有一个问题,就是如果系统中存在一些“恶意”的Filter,那么这个智能连接机制就会受到严峻的考验。因为这个原因,基于智能连接机制的应用程序(比如Windows Media Player)也会变得不稳定。(笔者并不赞成直接使用DirectShow Editing Services API进行非线性编辑,就是出于这方面的考虑。) |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:56:00 -- 玩转 DVR-MS
发布日期: 6/7/2005 | 更新日期: 6/7/2005
Stephen Toub 适用于: 摘要:Stephen Toub 讨论了 Windows XP Media Center 2005 生成的 DVR-MS 文件格式,介绍了 DirectShow 并展示了如何使用后者处理前者。 下载 DVR-MS 示例 Code.msi。 本页内容
几年前我拥有一台 TiVo。它已经不知藏在公寓壁橱的哪个角落了,我想现在一定是布满灰尘,诚然,就是现在我也可能这样对待它。占据电视旁宝贵空位的是一个更漂亮、更复杂的现代化软件和电子产品 — Microsoft Windows XP Media Center 2005。我的家人为该设备取了个既得体又人性化的名字 —“米老鼠”,它有许多神奇的功能。然而,当我建议我的“技术娴熟”的朋友们放弃他们现在使用的任一款数字摄像机 (DVR) 而转为使用此平台时,只要他们让我说明一个理由,我的回答都很简单:可以对录制的电视节目进行文件访问。 DVR-MS 文件是由 Windows XP Service Pack 1 引入的流缓冲引擎(Stream Buffer Engine,SBE)创建的,Media Center 用它存储录制的电视节目。在本文中,我将向您演示如何通过托管代码使用 DirectShow 来处理和操作 DVR-MS 文件。在此过程中,我将向您介绍我为处理 DVR-MS 文件而创建的一些有用的实用工具,并为您提供您在编写自己的代码时需要的工具和库。所以,请打开 Visual Studio .NET,抓一把爆米花,享受这个过程吧。 注 本文假定您的系统中有一个正在工作的 MPEG2 解码器,并且您使用的是 NTSC 而非 HD 内容(虽然这里讨论的大多数概念适用于 PAL 和 HD,但示例代码可能无法正确地处理这些格式)。另外,由于内容所有者或广播公司所设置的策略,一些 DVR-MS 文件受到复制保护。这种保护是在生成文件时通过检查广播公司的复制保护标志 (CGMS-A) 确定的,它会限制您访问特定 DVR-MS 文件的方式和时间。例如,在收费台(如 HBO)录制的电影可能是加密的,因此本文描述的技术就不适用了。最后,与本文相关联的代码示例和应用程序是针对 .NET Framework 1.1 编译的。然而,默认情况下 Windows XP Media Center 2005 并没有附带安装 .NET Framework 1.1,而是安装 1.0。因此,要在您的 Media Center 中使用这些示例,您必须安装 .NET Framework 1.1(可通过 Windows Update 获得)或者重新编译该示例以适用 .NET Framework 1.0。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:56:00 -- 播放 DVR-MS 文件谈到视频文件时,播放或许是可以执行的最重要的操作,所以我将从此入手。在您自己的应用程序中可以有多种播放 DVR-MS 文件的方式,这里我将演示其中的一些。为此,我创建了一个简单的应用程序(如图 1 所示),您可以在与本文有关的代码下载中获得。 图 1. 播放 DVR-MS 文件的示例应用程序 播放 DVR-MS 文件的第一种方式也是最简单的方式是,使用 System.Diagnostics.Process 类来执行它。由于 Process.Start 包装了来自 shell32.dll 的 ShellExecuteEx 非托管函数,因此这种方式利用了与从 Windows Explorer 双击一个文件相同的功能来播放 DVR-MS 文件: private void btnProcessStart_Click(object sender, System.EventArgs e){ Process.Start(txtDvrmsPath.Text);} 这也意味着,视频将在一个独立的进程中播放,这个进程在 DVR-MS 文件的任何默认处理程序中运行;对于大多数机器和我的机器来说,它就是 Windows Media Player(我使用 Windows Media Player 10,如果您没有,我建议您从 http://www.microsoft.com/windows/windowsmedia/mp10/default.aspx 免费升级到该版本)。当然,Process.Start 有另一个同时接受可执行路径和参数的重载,可以使用它在任何您想要的播放机中启动 DVR-MS 文件,不管它是否是 .dvr-ms 扩展名的默认处理程序: private void btnProcessStart_Click(object sender, System.EventArgs e){ Process.Start( @"c://Program Files//Windows Media Player//wmplayer.exe", "//"" + txtDvrmsPath.Text + "//"");} 您应该注意到,当这样做时,有必要对 DVR-MS 文件的路径加上引号(正如此处名为 txtDvrmsPath 的 TextBox 的内容所提供的),因为要使用的内容是 wmplayer.exe 的一个命令行参数。否则,路径中的任何空格都会使路径被分隔并解释为多个参数。 Process.Start 返回一个代表启动进程的 Process 实例,这意味着您可以利用 Process 提供的功能来与 Windows Media Player 进一步交互。例如,在您的应用程序中,您可能想先等待视频停止再让用户继续,可以使用 Process.WaitForExit 方法来完成这样的任务: private void btnProcessStart_Click(object sender, System.EventArgs e){ using(Process p = Process.Start(txtDvrmsPath.Text)) { p.WaitForExit(); }} 当然,这只是等待 Media Player 关闭,而不是像初始请求那样播放您指定的文件,因为您的应用程序没有真正的视图可以查看 Media Player 执行的内容。当打开 Media Player 时,按上述方法编码也会冻结应用程序的 GUI,这个问题可以通过订阅 Process 的 Exited 事件加以解决,而不是用 WaitForExit 方法阻止。 总而言之,该解决方案编码简单方便,但非常不灵活,而且是在应用程序的外部播放视频。它可能只在以下情况下才适用,您想允许用户查看指定的文件,不过是在应用程序不必关心视频内容而且应用程序根本不与视频交互的情况下查看。例如,如果您的应用程序是一个下载代理,而且您想允许用户查看已经复制到本地的视频文件,则可能适合采用这种方式。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:57:00 -- 由于我们知道 Windows Media Player 可以播放 DVR-MS 文件,因此对于大多数情况,更好的解决方案是在应用程序中宿主 Windows Media Player ActiveX 控件的一个实例。在 Visual Studio .NET 中,只需右键单击工具箱,选择添加控件并选择 Windows Media Player COM 控件。这样它就会出现在工具箱中,如图 2 所示。 图 2. 工具箱中的 Windows Media Player ActiveX 控件 当窗体中有一个 ActiveX 控件的实例时,让它播放 DVR-MS 文件就只需设置播放器的 URL 属性: player.URL = txtDvrmsPath.Text; 在我的示例应用程序中,我选择让它更进一步。我创建了一个 System.Windows.Forms.Panel,它位于想要显示视频的窗体中。当用户请求使用 Media Player 播放选定的视频时,我就新建一个 Media Player 控件的实例,将它添加到 Panel 的子控件集合中,使其保持在最大化,并设置其 URL 属性。这种方案允许我完全控制 Media Player 的生存期,而且可以轻松管理它在窗体中的位置,而不用担心它的绝对定位值(这种方案也使演示播放视频的其他方法变得轻松,稍后您将看到)。正在使用的这种方案的屏幕快照如图 3 所示,下面显示的是我使用的代码: private void btnWmp_Click(object sender, System.EventArgs e){ AxWindowsMediaPlayer player = new AxWindowsMediaPlayer(); pnlVideo.Controls.Add(player); player.Dock = DockStyle.Fill; player.PlayStateChange += new _WMPOCXEvents_PlayStateChangeEventHandler( player_PlayStateChange); player.URL = txtDvrmsPath.Text;}private void player_PlayStateChange( object sender, _WMPOCXEvents_PlayStateChangeEvent e){ AxWindowsMediaPlayer player = (AxWindowsMediaPlayer)sender; if (e.newState == (int)WMPLib.WMPPlayState.wmppsMediaEnded || e.newState == (int)WMPLib.WMPPlayState.wmppsStopped) { player.Parent = null; // removes the control from the panel ThreadPool.QueueUserWorkItem( new WaitCallback(CleanupVideo), sender); }} private void CleanupVideo(object video){ ((IDisposable)video).Dispose();} 图 3. 使用 WMP 控件的嵌入式 DVR-MS 播放 要阻止显示 Media Player 工具栏,您可以更改控件的 uiMode 属性: player.uiMode = "none"; 要在用户右键单击控件时阻止显示 Media Player 上下文菜单,可以将其 enableContextMenu 属性设置为 false: player.enableContextMenu = false; 您将注意到,在播放 DVR-MS 文件的前一刻,我为播放器的 PlayStateChange 事件注册了一个事件处理程序。这可以使我在播放停止时从 Panel 删除播放器。在 PlayStateChange 事件的处理程序中,我检查播放是否结束,如果结束,就将播放器从其父控件(面板)删除,并将一个工作项排入 .NET ThreadPool 队列中。这个工作项的作用只是处置播放器控件。我是在后台线程中进行此次处置的,因为无法在 PlayStateChange 事件处理程序中直接处置。在此事件处理程序中处置控件会在控件本身中引发异常,因为事件处理程序是在控件中引发的,控件在执行完我的处理程序之后还需要进行更多的处理。在处理程序中处置播放器控件会导致功能被破坏,所以我让该操作在事件处理程序完成之后稍微延迟一会,以便留出必要的时间。您将看到,在使用所演示的下一个播放机制时,就需要用到同一技术。 宿主 Windows Media Player ActiveX 控件有许多好处。它使用起来非常方便,而且提供了大量的功能。然而,Windows Media Player 使用 DirectX(特别是 DirectShow)来播放 DVR-MS 文件(本文后面我将更详细地讨论 DirectShow)。您不是依赖 Windows Media Player 与 DirectX 交互,而是在您的应用程序中使用 Managed DirectX,完全跳过 Windows Media Player。 在写作本文时 Managed DirectX 的最新版本是 DirectX 9.0 SDK Update February 2005 下载的一部分。(要获得本文后面介绍的内容,您还需要 February 2005 Extras 下载。)此 SDK 在您的全局程序集缓存 (GAC) 中安装了 AudioVideoPlayback.dll 程序集,使其可用于您的应用程序(DirectX 运行库安装也安装了此 DLL 以使您的最终用户可以访问它)。AudioVideoPlayback 是一个高级包装,它含有您在 .NET 应用程序中播放视频和音频文件所需要的最少的 DirectShow 功能。 有了 Windows Media Player ActiveX 控件后,使用 AudioVideoPlayback 变得非常简单。 private void btnManagedDirectX_Click(object sender, System.EventArgs e){ Video v = new Video(txtDvrmsPath.Text); Size s = pnlVideo.Size; v.Owner = pnlVideo; v.Ending += new EventHandler(v_Ending); v.Play(); pnlVideo.Size = s;}private void v_Ending(object sender, EventArgs e){ ThreadPool.QueueUserWorkItem( new WaitCallback(CleanupVideo), sender);}private void CleanupVideo(object video){ ((IDisposable)video).Dispose();} 这段代码首先实例化一个新的 Microsoft.DirectX.AudioVideoPlayback.Video 对象,然后将要播放的 DVR-MS 文件的路径提供给它。当播放一段 Video 时,它会自动将自身的大小(更具体地说是将它的所有者控件)调整为所播放视频的合适大小;为了解决这个问题,我存储了父面板控件的原始大小,这样在开始播放后就可以重置其大小。就像处理 ActiveX 控件那样,我注册了一个要在播放停止时激发的事件处理程序,然后播放视频。当播放结束时,我将一个工作项排入要处置 Video 对象的 ThreadPool 队列中,如同使用 ActiveX 控件一样(原因也相同)。当您不再使用 Video 对象时,对其进行处置是非常重要的;否则会浪费大量非托管资源,而且由于此对象有一个非常小的托管占地,垃圾回收器 (GC) 没有重大的动因可以及时进行回收,这样将使这些非托管资源的分配情况不明,除非您手动通过 IDisposable 处置。图 4 中的屏幕快照演示了 AudioVideoPlayback 功能的使用。 图 4. 采用 AudioVideoPlayback 的嵌入式播放 当然,虽然 AudioVideoPlayback 是一个高级 DirectShow 包装,但并不意味着您不能创建自己的托管包装(实际上,在本文后面我们将这样做)。创建托管包装的最简单方式是使用 tlbimp.exe(或者采用类似的做法 — 使用 Visual Studio .NET 的 COM 类型库导入功能。Visual Studio .NET 和 tlbimp.exe 都依赖于 Framework 中同样的库执行导入)。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:57:00 -- DirectShow 运行库的核心库是 quartz.dll,位于 %windir%//system32//quartz.dll。它包含用于音频和视频播放的最重要的 COM 接口和 coclass,本文后面将对此进行更加详细的讨论。在 quartz.dll 上运行 tlbimp.exe 会产生一个 interop 库 — Interop.QuartzTypeLib.dll(此程序集的描述信息为“ActiveMovie control type library”,因为 DirectShow 的前身名为 ActiveMovie),并公开 FilgraphManagerClass(筛选器图形管理器)和 IVideoWindow 接口。要播放视频,您只需创建该图形管理器的一个新实例并使用 RenderFile 方法,在 DVR-MS 文件路径中传送,以便初始化该对象以进行播放。然后可以使用由 FilgraphManagerClass 实现的 IVideoWindow 接口来控制播放选项,例如所有者窗口、视频在父窗口中的位置,以及视频窗口的标题。要开始播放,可以使用 Run 方法。WaitForCompletion 方法可以用于等待视频停止播放(或者,可以指定一个正的毫秒数,作为要等待的最长时间),Stop 方法可以用于暂停播放。要销毁该对象并释放用于播放的所有非托管资源(包括播放窗口本身),System.Runtime.InteropServices.Marshal 类及其 ReleaseComObject 方法就会派得上用场了。使用 quartz.dll 的屏幕快照如图 5 所示。 private void btnQuartz_Click(object sender, System.EventArgs e){ FilgraphManagerClass fm = new FilgraphManagerClass(); fm.RenderFile(txtDvrmsPath.Text); IVideoWindow vid = (IVideoWindow)fm; vid.Owner = pnlVideo.Handle.ToInt32(); vid.Caption = string.Empty; vid.SetWindowPosition(0, 0, pnlVideo.Width, pnlVideo.Height); ThreadPool.QueueUserWorkItem(new WaitCallback(RunQuartz), fm);}private void RunQuartz(object state){ FilgraphManagerClass fm = (FilgraphManagerClass)state; fm.Run(); int code; fm.WaitForCompletion(Timeout.Infinite, out code); fm.Stop(); while(Marshal.ReleaseComObject(fm) > 0);} 图 5. 使用 quartz.dll 的嵌入式播放 我刚刚向您介绍了一些在自己的应用程序中播放 DVR-MS 文件的方法。虽然我讨论了多个播放 DVR-MS 文件的方法(而且我还没列举完),但所有这些方法都要依赖于 DirectShow 才有播放功能。因此,我们将简要介绍一下 DirectShow(或者让那些具有 DirectShow 经验的人重温一下)。 返回页首
DirectShow 和 GraphEdit 简介在本质上,使用 DirectShow 处理视频文件的应用程序是通过一组称为筛选器的组件完成的。一个筛选器通常只对多媒体数据流执行一种操作。这样的筛选器很多,每个筛选器执行不同的任务,例如读取 DVR-MS 文件、写出 AVI 文件、对 MPEG-2 压缩视频进行解码、将视频和音频呈现到视频卡和声卡上,等等。这些筛选器的实例可以连接在一起并组合成一个筛选器图形,然后由 DirectShow 筛选器图形管理器组件进行管理(在前面介绍 quartz.dll 时,您已简要地对其进行了了解)。这些图形是定向的,也是非循环的,这意味着两个筛选器之间的特定连接只允许数据朝一个方向流动,而且只能流经特定筛选器一次。这种数据流程称为流 (stream),而筛选器则用来处理这些流。筛选器是通过它们公开的针 (pin) 连接到其他筛选器的,因此,一个筛选器的输出针连接到另一个筛选器的输入针,并按从前者发送到后者的方式发送数据流。 为了对此进行演示并显示本文中所使用的图形,我使用了 DirectX SDK 中一个名为 GraphEdit 的实用工具。GraphEdit 可以用来使筛选器图形可视化,当要确定如何构建用于特定目的的图形以及调试您所构建的图形时,这个功能就能派上用场。稍后,我将介绍如何使用 GraphEdit 来对在您的应用程序中运行的筛选器图形进行连接和可视化。 现在,我们运行 GraphEdit。在“File”菜单下,选择“Render Media File”,然后选择本地可用的任何有效的 DVR-MS 文件(请注意,您可能需要在“Open File”对话框中将筛选器扩展名更改为“All Files”,而不是“All Media Files”,因为最近发布的 GraphEdit 版本并没有将 .dvr-ms 扩展名归类为媒体文件)。您应该能够看到一个图形,它类似于图 6 所示的图形。 图 6. GraphEdit 准备播放 DVR-MS 文件 此时,GraphEdit 已构造了一个筛选器图形,它能够播放选定的 DVR-MS 文件。这些蓝框中的每一个都是一个筛选器,箭头显示每个筛选器上的输入和输出针如何互相连接以形成图形。图形中的第一个筛选器是 StreamBufferSource 筛选器的实例,它由 Windows XP SP1 及更高版本的 %windir%//system32//sbe.dll 库公开。选择这个筛选器是因为它在注册表中配置为 .dvr-ms 扩展名的源筛选器 (HKCL//Media Type//Extensions//.dvr-ms//Source Filter)。它的作用是从磁盘中读取一个文件,并将该文件的数据以流的形式发送到图形的其他部分。它从一个 DVR-MS 文件提供三个流。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:58:00 -- 第一个是音频流。如果您检查第一个针的针属性(DVR Out - 1,可以通过右键单击 GraphEdit 中的针来访问针属性),您可以发现该针的主要类型是 Audio,而其子类型是 Encrypted/Tagged,这意味我们在对该数据进行任何操作之前必须先对它进行解密和/或取消标记。这个过程是由 Decrypter/Detagger 筛选器(由 %windir%//system32//encdec.dll 公开)处理的。Decrypter/Detagger 将加密/带标记的音频流作为输入,然后发出 MPEG-1 音频流(对于高清晰度的内容则输出 dolby-AC3 流),这一点您可以通过检查该筛选器的 In(Enc/Tag) 和 Out 针加以验证。这里将音频发送到 MPEG Audio Decoder 筛选器(由 quartz.dll 公开),通过它将音频解压缩为脉冲编码调制 (PCM) 音频流。音频流的最后一个筛选器 DirectSound Audio Renderer(也由 quartz.dll 公开)接收此 PCM 音频数据并在计算机的声卡上播放。 DVR-MS 源筛选器提供的第二个流包含所录制的电视节目的闭合字幕数据。和音频流一样,闭合字幕流也是经过加密/标记的,所以它必须首先通过 Decrypter/Detagger 筛选器。如果查看此筛选器的 Out 针,您会发现其主要类型是 AUXLine21Data,而其子类型是 Line21_BytePair。电视节目中的闭合字幕是作为电视图像的一部分发送的,并专门编码到图像的 line 21 中。 DVR-MS 源筛选器发出的第三个流是视频内容 (video feed)。与音频和闭合字幕数据一样,这个流也是经过加密/标记的,所以它必须首先通过 Decrypter/Detagger 筛选器。Decrypter/Detagger 筛选器的输出是 MPEG-2 视频流,所以它必须先通过 MPEG-2 视频解码器才能呈现视频。Microsoft 没有在 Windows 中附带 MPEG-2 解码器,所以系统中必须有可用的第三方解码器才能播放。解码后的视频流再送到默认的视频呈现程序(由 quartz.dll 公开)。 单击图形上方的绿色播放按钮就会出现一个标题为 ActiveMovie Window 的新窗口并在该窗口中播放 DVR-MS 文件。请注意,由于闭合字幕 Decrypt/Tag Out 针没有连接到任何地方,因此在呈现视频时没有用到闭合字幕数据。您可以通过修改图形对此进行更改。实际做法是,首先删除默认的视频呈现程序(单击该筛选器并按“Delete”键),因为该呈现程序不能处理多路输入。具体来说,我们需要这样的呈现程序:它可以显示视频流,并能将包含呈现的闭合字幕数据的位图覆盖其上。如何从 Decrypter/Detagger 筛选器获取 line 21 字节对,将其作为位图呈现出来呢?Windows 实际上附带了一个正好可以完成此任务的 DirectShow 筛选器。使用“Graph”菜单下的“Insert Filters...”命令,展开树视图中的 DirectShow 筛选器节点并选择“Video Mixing Renderer 9”筛选器。单击“insert”按钮将此筛选器的实例添加到图形中,然后关闭“insert filters”对话框。现在,Video Mixing Renderer 9 筛选器成为图形的一部分了,但没有连接到任何地方,也就不能使用(实际上,如果您现在单击“play”按钮,则只播放音频,因为视频流没有连接到呈现程序)。单击 MPEG-2 解码器上的 Video Output 针,并将它拖到呈现程序的 VMR Input0 针上(请注意,如果您使用的解码器不是 NVDVD,则视频输出针的名称可能不同,但概念是一样的)。如果您现在播放图形,则会看到输出与使用默认视频呈现程序播放时基本一致。然而,您将看到,此时呈现程序筛选器公开了多个输入针(实际上,筛选器可以根据连接到它们的其他筛选器动态更改公开的针)。我们可以将闭合字幕 Decrypter/Detagger 筛选器的 Out 针连接到呈现程序的 VMR Input1 针上,以此利用这一特性。GraphEdit 会自动插入一个 Line 21 Decoder 2 筛选器,将 Decrypter/Detagger 筛选器连接到解码器筛选器,并将解码器筛选器连接到呈现程序筛选器。现在,您应该能看到如图 7 所示的图形。当您播放此图形时,您将看到闭合字幕像您期望的那样,以文本的形式出现在视频前。 图 7. 将闭合字幕合并到视频显示中 此时,对 DirectShow 不熟悉的读者可能会产生疑惑:是如何发现 Line 21 Decoder 2 筛选器的?为什么一开始只需使用 GraphEdit 的 Render Media File 操作就能构造出整个图形呢?GraphEdit 依赖 IGraphBuilder 接口提供的功能来查找和选择合适的筛选器,并在需要时将它们互连(IGraphBuilder 是由我们在介绍如何播放 DVR-MS 文件时简要提到的 FilgraphManager 组件实现的,实际上我们使用的 RenderFile 方法就是 IGraphBuilder 接口的一部分)。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:58:00 -- 这种用于自动构建筛选器图形的机制称为 Intelligent Connect。由于您并不真的需要知道 Intelligent Connect 的具体内容(除非您正在实现自己的筛选器并想让它们可以自动构建图形),因此在这里此主题我不想介绍得太多,而是让您参考 DirectX SDK 中该主题的详细文档。然而,简单地说,RenderFile 方法是一个简单的包装,它包装了 IGraphBuilder 中的另外两个方法:AddSourceFilter 和 Render。RenderFile 首先调用 AddSourceFilter,对于本地文件,它只需在注册表中查找正在播放的文件的扩展名所必需的源筛选器的类型,将适当的筛选器实例添加到筛选器图形中,并对它进行配置以使其指向指定的源文件。对于此源筛选器的每个输出针,RenderFile 再调用 Render 方法,该方法试图查找从此针到图形中的呈现程序的一条路径。如果该针实现了 IStreamBuilder 接口,则 Render 只是委托该实现,将所有细节都交给该筛选器的实现。否则,Render 会试图查找此针可以连接的筛选器。为此,它会查找在图形构建过程前期可能缓存的缓存筛选器,查找已经成为图形的一部分且有未连接的输入针的任何筛选器,并使用 IFilterMapper 接口查找注册表中兼容的筛选器类型。如果找到了一个筛选器,则它会再对这个新的筛选器重复此过程,直到到达呈现筛选器,此时就成功地停止。如果没有找到筛选器,则 Intelligent Connect 构建图形未成功。这就是依赖 Intelligent Connect 的一个缺点:它并非始终有效。另外,如果您的机器上安装了新的筛选器,则 Intelligent Connect 可能会选择这些新的筛选器,而不是您当前期望在应用程序中使用的筛选器。因此,您在设计时可能要选择避免这种情况(我后面将要介绍,如果您确切地知道想在图形中使用哪些筛选器,则显式构建图形而不使用 Intelligent Connect 是很容易的)。 既然您对 DirectShow 已有所了解,我们将要以编程方式使用它,以便对 DVR-MS 文件进行许多很合适的操作。毕竟,一旦 DVR-MS 源筛选器加载到图形中,我们就可以像处理其他音频和视频数据流那样处理来自 DVR-MS 的数据,操作它们的方法是无限的。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:58:00 -- DirectShow 接口然而,我们首先需要的是能够以编程方式处理 DirectShow。对于非托管代码,这可能是立即可行的,因为 SDK 包含了通过 C++ 访问 DirectShow 库所需要的所有头文件。对于托管代码,问题就有些棘手。虽然 Managed DirectX 确实包含前面讨论的 AudioVideoPlayback.dll 库,但该库级别很高,它提供 Video 和 Audio 级别的抽象,而我们需要的是能够在筛选器和针级别对筛选器图形进行操作。虽然我觉得这个问题将来会得到改善,但至少当前版本的 Managed DirectX 对我们爱莫能助。 quartz.dll 是什么?quartz.dll 的类型库公开了一些我们需要的功能,这里列出所公开接口的完整列表 [此贴子已经被作者于2005-11-26 3:03:44编辑过]
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 2:59:00 --
[此贴子已经被作者于2005-11-26 3:12:42编辑过]
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:13:00 -- 这确实是个很好的开头,但它没有为我们提供一些处理图形和筛选器的最重要的接口。例如,手动构造图形比较常用的接口之一,IGraphBuilder 接口,并没有包括在内。表示特定筛选器实例和提供对其针访问的 IBaseFilter 接口也没有包括在内。下表列出了在本文中要完成图形需要访问的主要接口: |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:15:00 --
另外,我还需要显式实例化各个 COM 类,下面展示了其中最重要的一些类,以及它们的类 ID 和对每个类的描述:
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:16:00 -- 正如 Eric Gunnerson 在关于 DirectShow 和 C# 的 his blog entry 中指出的,一种快捷简便的导入接口的方法是使用 DirectX SDK 附带的 DirectShow 接口定义语言(Interface Definition Language,IDL)文件。这些文件包含了 COM 接口定义,我对其中的大部分接口都很感兴趣。我可以创建自己的 IDL 文件(它的创作是为了产生一个类型库),然后通过 Microsoft 接口定义语言 (MIDL) 编译器 (midl.exe) 运行它。这将产生一个类型库,然后我再使用 .NET Framework tool Type Library Importer (tlbimp.exe) 将它转换成托管程序集。 遗憾的是,Eric 也指出,它不是一个完美的解决方案。首先,随 DirectX SDK 附带的 IDL 文件并没有描述我需要的所有接口,例如 IMediaEvent 和 IMediaControl。其次,即使我需要的所有接口都描述了,但通常需要对 interop 签名的创建进行更多控制,而不只是 tlbimp.exe 所提供的控制。例如,如果在图形运行完成之前用户指定的时间到期,则 IMediaEvent.WaitForCompletion(本文后面将会介绍)会返回一个 E_ABORT HRESULT;它将转换成在 .NET 中引发的异常,如果您在轮询循环中要频繁调用 WaitForCompletion(我就打算这样做),则这样做就不合适。另外,IDL 类型和托管类型之间并不是一对一的映射;实际上,存在这样的情况,类型可能根据使用它的上下文不同而进行不同的封送处理。例如,在 DirectX SDK 的 axcore.idl 文件中,IEnumPins 接口公开了以下方法: HRESULT Next( [in] ULONG cPins, // Retrieve this many pins. [out, size_is(cPins)] IPin ** ppPins, // Put them in this array. [out] ULONG * pcFetched // How many were returned?); 当它编译成类型库并由 tlbimp.exe 进行转换时,产生的程序集包含以下方法: void Next( [In] uint cPins, [Out, MarshalAs(UnmanagedType.Interface)] out IPin ppPins, [Out] out uint pcFetched); 虽然非托管的 IEnumPins::Next 可以被任何正整数值的 cPins 调用,但如果调用托管版本用的 cPins 值不是 1,则会产生错误,因为 ppPins 不是 IPin 实例数组,而是单个 IPin 实例的引用。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:16:00 -- 基于所有这些原因,以及 DirectShow 接口相对简单,我选择手动用 C# 实现 COM 接口 interop 定义;虽然这需要的工作更多,但它可以让您最好地控制封送内容、方式和时间(不过,请注意,在创建这些手动编码的 interop 定义时,采用 tlbimp.exe 生成的 MSIL 是一个很好的起点,或者更好的方式 — 采用这些导入类型库的反编译 C# 实现,可以使用 Lutz Roeder 的 .NET 发送程序生成它,这个程序可以从 http://www.aisto.com/roeder/dotnet/ 获得)。在与本文有关的代码下载中,您会发现我在本文中使用的每个非托管 DirectShow 接口都有手动编码的 C# 接口。举个例子,下面是前面讨论的 IGraphBuilder 接口的 C# 实现: [ComImport][Guid("56A868A9-0AD4-11CE-B03A-0020AF0BA770")][InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]public interface IGraphBuilder{ void AddFilter([In] IBaseFilter pFilter, [In, MarshalAs(UnmanagedType.LPWStr)] string pName); void RemoveFilter([In] IBaseFilter pFilter); IEnumFilters EnumFilters(); IBaseFilter FindFilterByName( [In, MarshalAs(UnmanagedType.LPWStr)] string pName); void ConnectDirect([In] IPin ppinOut, [In] IPin ppinIn, [In, MarshalAs(UnmanagedType.LPStruct)] AmMediaType pmt); void Reconnect([In] IPin ppin); void Disconnect([In] IPin ppin); void SetDefaultSyncSource(); void Connect([In] IPin ppinOut, [In] IPin ppinIn); void Render([In] IPin ppinOut); void RenderFile( [In, MarshalAs(UnmanagedType.LPWStr)] string lpwstrFile, [In, MarshalAs(UnmanagedType.LPWStr)] string lpwstrPlayList); IBaseFilter AddSourceFilter( [In, MarshalAs(UnmanagedType.LPWStr)] string lpwstrFileName, [In, MarshalAs(UnmanagedType.LPWStr)] string lpwstrFilterName); void SetLogFile(IntPtr hFile); void Abort(); void ShouldOperationContinue();} 然后就可以通过我的 IGraphBuilder 接口来转换和使用筛选器图形管理器组件的实例。那么,如何获取筛选器图形管理器组件的实例呢?我使用了如下代码: public class ClassId{ public static readonly Guid FilterGraph = new Guid("E436EBB3-524F-11CE-9F53-0020AF0BA770"); public static readonly Guid WMAsfWriter = new Guid("7C23220E-55BB-11D3-8B16-00C04FB6BD3D"); public static readonly Guid DecryptTag = new Guid("C4C4C4F2-0049-4E2B-98FB-9537F6CE516D"); ... public static object CoCreateInstance(Guid id) { return Activator.CreateInstance(Type.GetTypeFromCLSID(id)); }} 在这个包装就位后,我就可以创建筛选器图形管理器的实例,配置能够播放 DVR-MS 文件的筛选器图形,以及播放文件,总共只需要五行代码: object filterGraph = ClassId.CoCreateInstance(ClassId.FilterGraph);((IGraphBuilder)filterGraph).RenderFile(pathToDvrmsFile);((IMediaControl)filterGraph).Run();EventCode status;((IMediaEvent)filterGraph).WaitForCompletion(Timeout.Infinite, out status); 既然我们知道如何通过托管代码使用 DirectShow,现在我们就来看看如何利用它做一些很酷的事情。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:17:00 -- 将编码转换为 WMV如果 Internet 搜索引擎能提供任何线索,则人们对 DVR-MS 文件想做的最流行的一件事情就是将它们转换成 Windows Media Video 文件。通过目前我们为处理 DVR-MS 文件和 DirectShow 而创建的框架,这个任务是很容易实现的。简单地说,我需要做的就是创建一个使用 DVR-MS 源筛选器和 WM ASF Writer 筛选器接收器(它编码并写出 WMV 文件)的图形,并在它们之间建立适当的筛选器和连接。我故意对这些中间筛选器含糊其词,因为我可以让 Intelligent Connect 替我查找和插入它们。作为说明手动进行此操作的简单性的例子,我们按照以下简单步骤在 GraphEdit 中创建适当的转换图形:
GraphEdit 将使用该图形的 RenderFile 方法来为 DVR-MS 文件添加一个源筛选器,并通过需要的一系列中间筛选器将它连接到适当的呈现程序。由于以上操作发生时 WM ASF Writer 筛选器接收器已经在图形中,因此使用 Intelligent Connect 的 RenderFile 会将流发送到该筛选器接收器上,而不是插入新的默认呈现程序筛选器。您应该能看到如图 8 所示的图形。 图 8. 将 DVR-MS 编码转换为 WMV 的图形 以编程方式进行这种转换是非常简单的,可以通过以下代码实现: // Get the filter graphobject filterGraph = ClassId.CoCreateInstance(ClassId.FilterGraph);DisposalCleanup.Add(filterGraph);IGraphBuilder graph = (IGraphBuilder)filterGraph;// Add the ASF writer and set the output nameIBaseFilter asfWriterFilter = (IBaseFilter) ClassId.CoCreateInstance(ClassId.WMAsfWriter);DisposalCleanup.Add(asfWriterFilter);graph.AddFilter(asfWriterFilter, null);IFileSinkFilter sinkFilter = (IFileSinkFilter)asfWriterFilter;sinkFilter.SetFileName(OutputFilePath, null);// Render the DVR-MS file and run the graphgraph.RenderFile(InputFilePath, null);RunGraph(graph, asfWriterFilter); 先创建一个筛选器图形,将 WM ASF Writer 筛选器添加到其中并配置为指向适当的输出文件路径,然后将 DVR-MS 文件添加到该图形中并使用图形的 RenderFile 方法来呈现。遗憾的是,这在控制 WMV 文件编码方式上并没有提供很多灵活性。为了做到这一点,我们需要用一个配置文件配置 WM ASF Writer,这可以通过在调用 RenderFile 之前插入以下代码来完成: // Set the profile to be used for conversionif (_profilePath != null){ // Load the profile XML contents string profileData; using(StreamReader reader = new StreamReader(File.OpenRead(_profilePath))) { profileData = reader.ReadToEnd(); } // Create an appropriate IWMProfile from the data IWMProfileManager profileManager = ProfileManager.CreateInstance(); DisposalCleanup.Add(profileManager); IntPtr wmProfile = profileManager.LoadProfileByData(profileData); DisposalCleanup.Add(wmProfile); // Set the profile on the writer IConfigAsfWriter2 configWriter = (IConfigAsfWriter2)asfWriterFilter; configWriter.ConfigureFilterUsingProfile(wmProfile); } 这段代码假定配置文件 PRX 文件的路径已经存储在字符串成员变量 _profilePath 中。首先,使用 System.IO.StreamReader 将该配置文件的 XML 内容读到一个字符串中。然后创建 Windows Media Profile Manager(通过 IWMProfileManager 接口访问),并使用该管理器的 LoadProfileByData 方法将配置文件加载到其中。这为我们提供了一个指向所加载的配置文件的接口指针,可以用它来配置 WM ASF Writer 筛选器。WM ASF Writer 筛选器实现了 IConfigAsfWriter2 接口,它提供了 ConfigureFilterUsingProfile 方法,这个方法可以根据接口指针指定的配置文件配置编写器。 创建和配置好图形之后,剩下的工作就是运行它,我是使用特意指定的 RunGraph 方法实现的。该方法首先获取指定图形的 IMediaControl 和 IMediaEvent 接口。它还试图获取可用于跟踪源 DVR-MS 文件处理进度的 IMediaSeeking 接口。然后使用 IMediaControl 接口来运行图形,从此时开始,方法中的剩余代码仅仅是用来跟踪转换的处理进度。在图形结束运行前,代码会不断轮询 IMediaEvent.WaitForCompletion 方法,如果等待时间已到但图形还没完成运行,则该方法将返回状态代码 EventCode.None (0x0)。如果发生这种情况,则会使用 IMediaSeeking 接口来查询已经处理多少 DVR-MS 文件以及该文件的持续时间,由此我可以计算文件处理的百分比。 当图形最终完成运行时,IMediaEvent.WaitForCompletion 会返回 EventCode.Complete (0x1),并使用 IMediaControl.Stop 来停止图形。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:17:00 -- protected void RunGraph( IGraphBuilder graphBuilder, IBaseFilter seekableFilter){ IMediaControl mediaControl = (IMediaControl)graphBuilder; IMediaEvent mediaEvent = (IMediaEvent)graphBuilder; IMediaSeeking mediaSeeking = seekableFilter as IMediaSeeking; if (!CanGetPositionAndDuration(mediaSeeking)) { mediaSeeking = graphBuilder as IMediaSeeking; if (!CanGetPositionAndDuration(mediaSeeking)) mediaSeeking = null; } using(new GraphPublisher(graphBuilder, Path.GetTempPath()+Guid.NewGuid().ToString("N")+".grf")) { mediaControl.Run(); try { OnProgressChanged(0); bool done = false; while(!CancellationPending && !done) { EventCode statusCode = EventCode.None; int hr = mediaEvent.WaitForCompletion( PollFrequency, out statusCode); switch(statusCode) { case EventCode.Complete: done = true; break; case EventCode.None: if (mediaSeeking != null) { ulong curPos = mediaSeeking.GetCurrentPosition(); ulong length = mediaSeeking.GetDuration(); double progress = curPos * 100.0 / (double)length; if (progress > 0) OnProgressChanged(progress); } break; default: throw new DirectShowException(hr, null); } } OnProgressChanged(100); } finally { mediaControl.Stop(); } }} 简单吧?DirectShow 是一项令人惊讶的技术。这段代码允许您将非 DRM/'d、NTSC、存储在 DVR-MS 文件中的 SD 内容转换成 WMV 文件。如果您检查本文代码下载中的文件,正如您将看到的,我已将此函数编码到一个名为 Converter 的抽象基类中。一个派生类(在本例中为 WmvConverter)构建合适的图形,然后调用基类的 RunGraph 方法。另外,Converter 还公开了可用于配置、监视和暂停图形流程的属性和事件,正如您在以下部分将看到的,Converter 公开了使调试图形变得更加简单的功能。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:19:00 -- 调试筛选器图形您将在 RunGraph 方法中看到,图形是在如下所示的 using 块内部运行的: using(new GraphPublisher(graphBuilder, Path.GetTempPath()+Guid.NewGuid().ToString("N")+".grf")){ ... // run the graph} 我这里使用的 GraphPublisher 类是一个自定义类,它是我为帮助调试图形而编写的。它有两个用途。第一,如果在 GraphPublisher 的构造函数的第二个参数中指定了一个文件路径,则它会将 graphBuilder 对象所表示的图形保存到该文件中(该文件应该使用 .grf 扩展名)。随后 GraphEdit 可以打开此文件,从而让您查看整个图形,如同它在发布时出现的样子。这个功能可以通过筛选器图形管理器的 IPersistStream 接口实现来使用: private const ulong STGM_CREATE = 0x00001000L;private const ulong STGM_TRANSACTED = 0x00010000L;private const ulong STGM_WRITE = 0x00000001L;private const ulong STGM_READWRITE = 0x00000002L;private const ulong STGM_SHARE_EXCLUSIVE = 0x00000010L;[DllImport("ole32.dll", PreserveSig=false)]private static extern IStorage StgCreateDocfile( [MarshalAs(UnmanagedType.LPWStr)]string pwcsName, [In] uint grfMode, [In] uint reserved);private static void SaveGraphToFile(IGraphBuilder graph, string path){ using(DisposalCleanup dc = new DisposalCleanup()) { string streamName = "ActiveMovieGraph"; IPersistStream ps = (IPersistStream)graph; IStorage graphStorage = StgCreateDocfile(path, (uint)(STGM_CREATE | STGM_TRANSACTED | STGM_READWRITE | STGM_SHARE_EXCLUSIVE), 0); dc.Add(graphStorage); UCOMIStream stream = graphStorage.CreateStream( streamName, (uint)(STGM_WRITE | STGM_CREATE | STGM_SHARE_EXCLUSIVE), 0, 0); dc.Add(stream); ps.Save(stream, true); graphStorage.Commit(0); }} 然而,GraphPublisher 的主要目的和它在 using 块中使用的原因是将实时图形发布到 GraphEdit。GraphEdit 允许您连接到另一个流程所公开的远程图形,只要该图形已经发布到运行中对象表 (ROT) — 一个用作跟踪运行对象的全局可访问的查找表。GraphEdit 不仅可以让您在另一个流程中查看和检查一个实时筛选器图形,它还常常允许您对其加以控制。 该图形发布到 ROT 是使用以下代码完成的: private class RunningObjectTableCookie : IDisposable{ private int _value; private bool _valid; internal RunningObjectTableCookie(int value) { _value = value; _valid = true; } ~RunningObjectTableCookie() { Dispose(false); } public void Dispose() { GC.SuppressFinalize(this); Dispose(true); } private void Dispose(bool disposing) { if (_valid) { RemoveGraphFromRot(this); _valid = false; _value = -1; } } internal bool IsValid { get { return _valid; } set { _valid = value; } }}private static RunningObjectTableCookie AddGraphToRot( IGraphBuilder graph){ if (graph == null) throw new ArgumentNullException("graph"); UCOMIRunningObjectTable rot = null; UCOMIMoniker moniker = null; try { // Get the ROT rot = GetRunningObjectTable(0); // Create a moniker for the graph int pid; using(Process p = Process.GetCurrentProcess()) pid = p.Id; IntPtr unkPtr = Marshal.GetIUnknownForObject(graph); string item = string.Format("FilterGraph {0} pid {1}", ((int)unkPtr).ToString("x8"), pid.ToString("x8")); Marshal.Release(unkPtr); moniker = CreateItemMoniker("!", item); // Registers the graph in the running object table int cookieValue; rot.Register(ROTFLAGS_REGISTRATIONKEEPSALIVE, graph, moniker, out cookieValue); return new RunningObjectTableCookie(cookieValue); } finally { // Releases the COM objects if (moniker != null) while(Marshal.ReleaseComObject(moniker)>0); if (rot != null) while(Marshal.ReleaseComObject(rot)>0); }}private static void RemoveGraphFromRot(RunningObjectTableCookie cookie){ if (!cookie.IsValid) throw new ArgumentException("cookie"); UCOMIRunningObjectTable rot = null; try { // Get the running object table and revoke the cookie rot = GetRunningObjectTable(0); rot.Revoke(cookie.Value); cookie.IsValid = false; } finally { if (rot != null) while(Marshal.ReleaseComObject(rot)>0); }}private const int ROTFLAGS_REGISTRATIONKEEPSALIVE = 1;[DllImport("ole32.dll", ExactSpelling=true, PreserveSig=false)]private static extern UCOMIRunningObjectTable GetRunningObjectTable( int reserved);[DllImport("ole32.dll", CharSet=CharSet.Unicode, ExactSpelling=true, PreserveSig=false)]private static extern UCOMIMoniker CreateItemMoniker( [In] string lpszDelim, [In] string lpszItem); 在其构造函数中,GraphPublisher 使用 AddGraphToRot 将图形添加到 ROT 中,并存储产生的 cookie。在其 IDisposable.Dispose 方法中,GraphPublisher 通过将存储的 cookie 传递到 RemoveGraphFromRot 来将图形从 ROT 中删除。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:21:00 -- 非托管资源清理当资源使用完毕后,尽早将它们释放是非常重要的。当使用处理大量音频和视频资源的 DirectShow COM 对象时,这一点尤其重要。可以使用 Marshal.ReleaseComObject 方法来强制处置 COM 对象,此方法会减少所提供的运行时可调用包装的引用计数。当引用数到达零时,运行库会释放它在非托管 COM 对象上的所有引用。(有关 Marshal.ReleaseComObject 的更多信息,请参见该方法的 MSDN 文档。)对于使用的每个 COM 对象,我不是将我的代码随便放在 try/finally 块中,而是创建一个名为 DisposalCleanup 的助手类,它可以简化 COM 对象的生存期管理: public class DisposalCleanup : IDisposable{ private ArrayList _toDispose = new ArrayList(); public void Add(params object [] toDispose) { if (_toDispose == null) throw new ObjectDisposedException(GetType().Name); if (toDispose != null) { foreach(object obj in toDispose) { if (obj != null && (obj is IDisposable || obj.GetType().IsCOMObject || obj is IntPtr)) { _toDispose.Add(obj); } } } } void IDisposable.Dispose() { if (_toDispose != null) { foreach(object obj in _toDispose) EnsureCleanup(obj); _toDispose = null; } } private void EnsureCleanup(object toDispose) { if (toDispose is IDisposable) { ((IDisposable)toDispose).Dispose(); } else if (toDispose is IntPtr) // IntPtrs must be interface ptrs { Marshal.Release((IntPtr)toDispose); } else if (toDispose.GetType().IsCOMObject) { while (Marshal.ReleaseComObject(toDispose) > 0); } }} 这里一个重要的方法是 EnsureCleanup,它是通过 DisposalCleanup 的 IDisposable.Dispose 方法调用的。通过使用其 Add 方法来调用添加到 DisposalCleanup 中的每个对象,EnsureCleanup 调用了一个 IDisposable 对象上的 Dispose、一个 COM 对象上的 Marshal.ReleaseComObject 和一个接口指针上的 Marshal.Release。通过这些,我的代码只需将使用许多 COM 对象的代码块放在一个创建了新的 DisposalCleanup 的 using 块中,将任何 COM 对象或接口添加到 DisposalCleanup 实例中,并在 using 块结束时调用 DisposalCleanup 的 IDisposable.Dispose 方法来释放所有使用过的资源。我的 Converter 基类实现了此方案,并通过一个受保护的 DisposalCleanup 属性公开了构造的 DisposalCleanup。 public object Convert() { _cancellationPending = false; try { object result; using(_dc = new DisposalCleanup()) { // Do the actual work result = DoWork.(); } OnConversionComplete(null, result); return result; } catch(DirectShowException exc) { OnConversionComplete(exc, null); throw; } catch(Exception exc) { exc = new DirectShowException(exc); OnConversionComplete(exc, null); throw exc; } catch { OnConversionComplete(new DirectShowException(), null); throw; }}private DisposalCleanup _dc;protected DisposalCleanup DisposalCleanup { get { return _dc; } } DoWork. 方法是抽象方法,如果是 WmvConverter 类,它可以构建筛选器图形并调用 RunGraph 方法。通过这种方式,派生类可以实现 DoWork. 并简单地向基类的 DisposalCleanup 中添加可处置的对象;当派生类的工作执行完毕后,即使它引发异常,基类也会自动处置这些资源。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:21:00 -- 将 WmvConverter 投入使用: WmvTranscoderPlugin显而易见,通过前面讨论的代码,您可以编写功能丰富的应用程序来处理 DVR-MS 文件并将其转换成 WMV 文件。但据我所见,此功能最常见的请求是作为 Media Center-集成解决方案的一部分。由此创建了许多非常有用的解决方案,其中最著名的有 Dan Giambalvo 创建的 dCut (可通过 http://www.inseattle.org/~dan/Dcut.htm 下载)以及 Alex Seigler、José Peña、James Edelen 和 Jeff Griffin 创建的 DVR 2 WMV(可通过 http://www.thegreenbutton.com/downloads.aspx 下载)。这两个应用程序都依赖于 Alex Siegler 编写的 dvr2wmv DLL(使用的技术与本文所介绍的非常类似,不过采用的是非托管代码)。这些应用程序不懈努力地尝试集成到 Media Center 中,更具体地说是模仿 Media Center 外壳的外观,但遗憾的是,目前的 Media Center SDK 只允许做到这么多。幸运的是,SDK 有另一个相对未开发的区域,它使这种功能可以轻松地集成到 Media Center UI 中,但仍然保留 Media Center 团队已编写的所有烙印:ListMaker 外接程序。 ListMaker 外接程序是由第三方提供的托管组件,它运行在 Media Center 进程内,使用 Microsoft.MediaCenter.dll 程序集公开的 API 元素(您可以在 Media Center 系统的 %windir%//ehome 目录下找到此 DLL)。ListMaker 外接程序的工作非常简单:它的目的是获取 Media Center 提供给它的文件列表,并对该列表进行一些操作(进行什么操作取决于该外接程序)。Media Center 已将它构建到 UI 中以处理列表生成和随外接程序处理列表时的报告而显示的进程更新。很酷的一点是 Media Center 并不在意该外接程序对媒体列表进行了什么操作。因此,您可以编写这样一个外接程序,它将用户选定的每个 DVR-MS 文件转换成 WMV,并将它们写到硬盘的一个文件夹中。更明确地说,我拥有这样的外接程序(图 9),下面我将向您介绍如何实现。 图 9. WMV Transcoder 外接程序 首先,ListMaker 外接程序必须从 System.MarshalByRefObject 派生,如同所有用于 Media Center 的外接程序那样(遗憾的是,SDK 文档目前没有提到这一点,但是这一点非常重要)。Media Center 将所有外接程序加载到一个独立的应用程序域中,这意味着它使用 .NET Remoting 基础结构跨应用程序域边界访问该外接程序。MarshalByRefObject 类能实现这一目的,它允许跨应用程序域边界访问对象,因此外接程序必须以它为基类。如果您忘记从 MarshalByRefObject 派生,则您的外接程序将无法正确加载或运行。 除了从 MarshalByRefObject 派生外,ListMaker 外接程序还实现了两个来自 Microsoft.MediaCenter.dll 程序集的主要接口:Microsoft.MediaCenter.AddIn.IAddInModule 和 Microsoft.MediaCenter.AddIn.ListMaker.ListMaker: public class WmvTranscoderPlugin : MarshalByRefObject, IAddInModule, ListMakerApp, IBrandInfo{ ...} 所有 Media Center 外接程序都实现了 IAddInModule,IAddInModule 通过实现 IAddInModule.Initialize 和 IAddInModule.Uninitialize 方法来初始化和处置要运行的代码。在许多情况下,初始化阶段需要做的事情非常少;对于我的外接程序,我只需查看一下注册表,找到用户首选项,例如经过编码转换的文件应该写到哪个磁盘(注册表中 HKLM//Software//Toub//WmvTranscoderPlugin 项的 PreferredDrive 值)以及应该使用哪个 Windows Media 配置文件来将代码转换为 WMV(注册表中的 HKLM//Software//Toub//WmvTranscoderPlugin 项的 ProfilePath 值)。如果没有指定驱动器(或者指定的驱动器无效),则我将默认值设置为从 System.IO.Directory.GetLogicalDrives 返回的第一个有效的驱动器,其中,有效的驱动器定义为 Win32 GetDriveType 函数声明的固定驱动器中的任何一个驱动器。 ListMakerApp 是列表的主要接口,用于处理和服务双重目的:允许用户选择要处理的媒体文件集(图 10)并启动外接程序的处理,在这之后它允许 Media Center UI 报告进度(图 11)。 图 10. 选择要进行编码转换的节目 图 11. Media Center 外壳中的进度更新 前者涉及的成员并不令人非常满意,所以我不想花太多时间介绍它们。从根本上说,Media Center 通过此接口调用外接程序以获取如选择多少 DVR-MS 文件、还能添加多少文件之类的信息,并在每次用户更改要处理的列表项时调用它。它的核心部分是由三个方法处理的: public void ItemAdded(ListMakerItem item){ _itemsUsed++; _bytesUsed += item.ByteSize; _timeUsed += item.Duration;}public void ItemRemoved(ListMakerItem item){ _itemsUsed--; _bytesUsed -= item.ByteSize; _timeUsed -= item.Duration;}public void RemoveAllItems(){ _itemsUsed = 0; _bytesUsed = 0; _timeUsed = TimeSpan.FromSeconds(0);} 然后通过其他属性和方法(如下所示)公开捕获的信息: public TimeSpan TimeUsed { get { return _timeUsed; } }public int ItemUsed { get { return _itemsUsed; } }public long ByteUsed { get { return _bytesUsed; } }public TimeSpan TimeCapacity { get { return TimeSpan.MaxValue; } } public int ItemCapacity { get { return int.MaxValue; } } public long ByteCapacity { get { return (long)GetFreeSpace(_selectedDrive); } } Used 方法只是返回上述方法所维护的计数值。TimeCapacity 和 ItemCapacity 属性同时返回其类型各自的 MaxValue 值,因为计算实际用时和实际可用的项数远远超出了本文的讨论范围。ByteCapacity 使用我的私有 GetFreeSpace 方法(再次说明,它只是 Win32 GetDiskFreeSpaceEx 函数的一个 p/invoke 包装)来返回磁盘中的可用空间;当然,在与 ByteUsed 配合时这个值也没有什么用处,因为 ByteUsed 表示的是 DVR-MS 文件的大小,而 ByteCapacity 则用于确定磁盘中是否有空间来存放这些文件,但输出文件却是压缩过的 WMV 文件。不过这个实现细节您应该能够自如地进行更改。 我还将介绍三个更加重要但实现很简单的属性: public MediaType SupportedMediaTypes { get { return MediaType.RecordedTV; } } public bool OrderIsImportant { get { return true; } }public IBrandInfo BrandInfo { get { return this; } } SupportedMediaTypes 返回一个加标记的枚举,列出此外接程序支持的媒体类型:可能的类型包括图片、视频、音乐和录制的电视等,Media Center 通常支持所有这些媒体类型。然而,由于此外接程序的主要作用是将 DVR-MS 文件转换成 WMV 文件,因此我将其实现为只从 SupportedMediaTypes 返回 MediaType.RecordedTV。 Media Center 使用 OrderIsImportant 来确定是否应该允许用户对要处理的录制节目列表重排序。虽然顺序对此外接程序来说并不是真的很重要(因为它只是将文件写到硬盘中),但我想让用户安排某些特定节目在其他节目之前转换(图 12),所以我从这个属性返回 true 而不是 false。 图 12. 对选定的节目重排序 BrandInfo 属性允许外接程序的作者修改 Media Center 显示的 UI 以便包含特定于产品的信息。该属性返回一个实现 IBrandInfo 接口的对象。为简单起见,我只在我的外接程序中实现该接口并返回对该外接程序对象自身的引用: public class WmvTranscoderPlugin : MarshalByRefObject, IAddInModule, ListMakerApp, IBrandInfo{ ... public IBrandInfo BrandInfo { get { return this; } } ... public string ViewListPageTitle { get { return "Files to transcode"; } } public string SaveListButtonTitle { get { return "Transcode"; } } public string PageTitle { get { return "Transcode to WMV"; } } public string CreatePageTitle { get { return "Specify target folder"; } } public string ViewListButtonTitle { get { return "View List"; } } public string ViewListIcon { get { return null; } } public string MainIcon { get { return null; } } public string StatusBarIcon { get { return null; } } ...} IBrandInfo 的八个属性被分成两类:呈现在 UI 中的文本字符串和指定磁盘中图形位置的路径字符串。如果一个属性返回 null,则使用默认值。这样,由于我现在的图形艺术水平还有些欠缺,因此对所有图标属性我都返回 null。这些属性在 UI 中出现的位置如下表所示: |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:22:00 --
|
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:23:00 -- ListMakerApp 上最有趣的方法是 Launch 和 Cancel。一旦用户创建了要处理的文件列表并单击按钮开始处理,Media Center 就会调用 Launch 方法,它提供三个参数:用户选择的录制节目列表、可被调用以通知 Media Center 状态更新的进程更新委托和应该调用以通知 Media Center 处理完成(成功或因某种异常情况)的完成委托。Launch 方法的作用是立即返回并在后台线程中执行实际的工作。当用户选择取消处理时就会调用 Cancel 方法,然后由外接程序停止和终止其操作。 WmvTranscoderPlugin 的实现遵循这种模式:将 Launch 的参数存储到成员变量中,然后将执行实际转换工作的 ConvertToWmv 方法排入 ThreadPool 队列中: public void Launch(ListMakerList lml, ProgressChangedEventHandler pce, CompletionEventHandler ce){ _listMakerList = lml; _progressChangedHandler = pce; _completedHandler = ce; _cancellationPending = false; ThreadPool.QueueUserWorkItem(new WaitCallback(ConvertToWmv), null);}private void ConvertToWmv(object ignored){ ThreadPriority oldThreadPriority = Thread.CurrentThread.Priority; Thread.CurrentThread.Priority = ThreadPriority.Lowest; try { DirectoryInfo outDir = Directory.CreateDirectory( _selectedDrive + ":" + _listMakerList.ListTitle); _currentConvertingIndex = 0; foreach(ListMakerItem item in _listMakerList.Items) { if (_cancellationPending) break; string dvrMsName = item.Filename; string wmvName = outDir.FullName + "" + item.Name + ".wmv"; _currentConverter = new WmvConverter( dvrMsName, wmvName, _profilePath); _priorCompletedPercentage = _currentConvertingIndex / (float)_listMakerList.Count; _currentConverter.PollFrequency = 2000; _currentConverter.ProgressChanged += new ProgressChangedEventHandler(ReportChange); _currentConverter.Convert(); _currentConverter = null; _currentConvertingIndex++; } _completedHandler(this, new CompletionEventArgs()); } catch(Exception exc) { _completedHandler(this, new CompletionEventArgs(exc)); } finally { Thread.CurrentThread.Priority = oldThreadPriority; }} ConvertToWmv 在选定的驱动器上创建一个目录,使用用户指定的目标文件夹的名称(参见图 13)。然后该方法循环访问所提供的 ListMakerList 中的所有 ListMakerItem 对象,获取 DVR-MS 文件的路径并使用前面构建的 WmvConverter 来将目标目录中的每个 DVR-MS 文件转换成 WMV 文件。Converter 的 ProgressChanged 事件关联到外接程序中的一个私有方法 — ReportChange 上,然后由该方法调用 Media Center 的进程更新委托。另外,当前转换程序存储在一个成员变量中,因而可以使用 Cancel 方法来停止其进程 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:23:00 -- Cancel 方法也非常简单。它设置了一个成员变量,用于警告在另一个线程中运行的 ConvertToWmv 方法,通知它用户已经请求取消。然而,正如您在 ConvertToWmv 方法中看到的,只有当该方法准备转换下一个 DVR-MS 文件时才会对此进行检查,所以 Cancel 方法还使用存储在一个成员变量中的 WmvConverter 对象,使用该 Converter 的 CancelAsync 方法取消当前执行的转换。正如我们前面所看到的,这将导致 Converter.RunGraph 方法从 WaitForCompletion 方法返回后即刻停止。 public void Cancel(){ // Cancel any pending conversions _cancellationPending = true; // Cancel the current conversion WmvConverter converter = _currentConverter; if (converter != null) converter.CancelAsync();} 我在本文的下载中包含了此外接程序的一个完整的工作实现,包括一个安装程序。该安装程序同时将 WmvTranscoderPlugin 的程序集和 WmvConverter 的程序集安装到全局程序集缓存 (GAC) 中,然后使用 RegisterMceApp.exe 工具来将此外接程序通知 Media Center。注册应用程序依赖于一个 XML 配置文件,如下所示: 您应该能够运行安装程序并直接通过一个我们都不必编写的非常时髦的 UI 来将 DVR-MS 立即转换成 WMV。(感谢你,Media Center 团队!) 图 14. 成功的编码转换 返回页首
访问 DVR-MS 元数据DVR-MS 文件格式既包含音频、视频和闭合字幕数据,也包含描述文件及其内容的元数据。一旦电视节目录制下来,节目的标题、描述、演员表和原始播放日期等信息就存储在这个位置。很酷的一点是,您的应用程序可以通过 DirectShow StreamBufferRecordingAttribute 对象实现的 IStreamBufferRecordingAttribute 接口轻松地访问此数据。这个对象可以使用它的 CLSID 来创建,正如我本文中创建其他 DirectShow 对象那样。 要使用 IStreamBufferRecordingAttribute,首先必须为它提供一个托管接口(您会在本文的代码下载中发现这段代码,它嵌套在 DvrmsMetadataEditor 类中): |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:24:00 -- [ComImport][Guid("16CA4E03-FE69-4705-BD41-5B7DFC0C95F3")][InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]private interface IStreamBufferRecordingAttribute{ void SetAttribute( [In] uint ulReserved, [In, MarshalAs(UnmanagedType.LPWStr)] string pszAttributeName, [In] MetadataItemType StreamBufferAttributeType, [In, MarshalAs(UnmanagedType.LPArray)] byte [] pbAttribute, [In] ushort cbAttributeLength); ushort GetAttributeCount([In] uint ulReserved); void GetAttributeByName( [In, MarshalAs(UnmanagedType.LPWStr)] string pszAttributeName, [In] ref uint pulReserved, [Out] out MetadataItemType pStreamBufferAttributeType, [Out, MarshalAs(UnmanagedType.LPArray)] byte[] pbAttribute, [In, Out] ref ushort pcbLength); void GetAttributeByIndex ( [In] ushort wIndex, [In, Out] ref uint pulReserved, [Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszAttributeName, [In, Out] ref ushort pcchNameLength, [Out] out MetadataItemType pStreamBufferAttributeType, [Out, MarshalAs(UnmanagedType.LPArray)] byte [] pbAttribute, [In, Out] ref ushort pcbLength); [return: MarshalAs(UnmanagedType.Interface)] object EnumAttributes();} 为了访问 DVR-MS 文件的元数据,我构造了一个 StreamBufferRecordingAttribute 对象并获取它的 IFileSourceFilter 接口(您在本文前面也看到了相应的 IFileSinkFilter 接口;它们几乎完全相同)。IFileSourceFilter 的 Load 方法可用于打开我对其元数据感兴趣的 DVR-MS 文件,此时可以获取它的 IStreamBufferRecordingAttribute 接口并将该接口用于检索和编辑元数据: public class DvrmsMetadataEditor : MetadataEditor{ IStreamBufferRecordingAttribute _editor; public DvrmsMetadataEditor(string filepath) { IFileSourceFilter sourceFilter = (IFileSourceFilter) ClassId.CoCreateInstance(ClassId.RecordingAttributes); sourceFilter.Load(filepath, null); _editor = (IStreamBufferRecordingAttribute)sourceFilter; } ...} 对元数据的读访问是通过 DvrmsMetadataEditor.GetAttributes 方法提供的,该方法提供了 IStreamBufferRecordingAttribute 的 GetAttributeCount 和 GetAttributeByIndex 方法的简单抽象。 public override System.Collections.IDictionary GetAttributes(){ if (_editor == null) throw new ObjectDisposedException(GetType().Name); Hashtable propsRetrieved = new Hashtable(); ushort attributeCount = _editor.GetAttributeCount(0); for(ushort i = 0; i < attributeCount; i++) { MetadataItemType attributeType; StringBuilder attributeName = null; byte[] attributeValue = null; ushort attributeNameLength = 0; ushort attributeValueLength = 0; uint reserved = 0; _editor.GetAttributeByIndex(i, ref reserved, attributeName, ref attributeNameLength, out attributeType, attributeValue, ref attributeValueLength); attributeName = new StringBuilder(attributeNameLength); attributeValue = new byte[attributeValueLength]; _editor.GetAttributeByIndex(i, ref reserved, attributeName, ref attributeNameLength, out attributeType, attributeValue, ref attributeValueLength); if (attributeName != null && attributeName.Length > 0) { object val = ParseAttributeValue( attributeType, attributeValue); string key = attributeName.ToString().TrimEnd(/'//0/'); propsRetrieved[key] = new MetadataItem( key, val, attributeType); } } return propsRetrieved;} 首先,使用 GetAttributeCount 方法来查明要检索的元数据项有多少。然后,对于每个属性,使用 GetAttributeByIndex 方法检索属性名的长度和值的长度(以字节为单位)(通过将 name 和 value 参数指定为空值)。当获得长度之后,我就可以创建大小适当的缓冲区来存储数据,并且可以再次调用 GetAttributeByIndex 来检索属性的真实名称和字节数组值。如果检索成功,则会根据属性的类型将存储该值的字节数组解析为适当的托管对象。我的 ParseAttributeValue 方法返回 GUID、无符号整型、无符号长整型、无符号短整型、字符串、布尔值或者原始数组(如果值是简单的二进制),这对大多数复杂的元数据属性都是通用的。然后使用该属性的名称及其类型和值构造一个新的 MetadataItem 实例,这个实例将添加到该文件的所有属性的 Hashtable 中。当所有属性都检索完毕时,此集合将返回给用户。 SetAttributes 方法的工作方式则相反。它是随 MetadataItem 对象集合提供的,其中每个对象都根据其类型格式化为适当的字节数组,然后与 SetAttribute 方法一起使用,以便设置文件的元数据属性: public override void SetAttributes(IDictionary propsToSet){ if (_editor == null) throw new ObjectDisposedException(GetType().Name); if (propsToSet == null) throw new ArgumentNullException("propsToSet"); byte [] attributeValueBytes; foreach(DictionaryEntry entry in propsToSet) { MetadataItem item = (MetadataItem)entry.Value; if (TranslateAttributeToByteArray( item, out attributeValueBytes)) { try { _editor.SetAttribute(0, item.Name, item.Type, attributeValueBytes, (ushort)attributeValueBytes.Length); } catch(ArgumentException){} catch(COMException){} } }} MetadataItem 是一个属性的名称、值和类型的简单包装。MetadataItemType 是有效类型(GUID、字符串、无符号整型等)的枚举。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:24:00 -- 您可能注意到 DvrmsMetadataEditor 类是从 MetadataEditor 基类派生的。我这样做是为了提供另一个类 — AsfMetadataEditor,它也是从 MetadataEditor 派生的。AsfMetadataEditor 基于包含在 Windows Media Format SDK(从此处下载 SDK)中的示例代码。它使用 Windows Media IWMMetadataEditor 和 IWMHeaderInfo3 接口来获取 WMA 和 WMV 文件(这两者都基于 ASF 文件格式)的相关元数据信息。您可能会发现,当前这些 Windows Media Format SDK 接口除了能用于处理 WMA 和 WMV 文件外,还可以处理 DVR-MS 文件,不过将来可能不再这样,而且 Microsoft 强烈建议使用 IStreamBufferRecordingAttribute 接口来处理 DVR-MS 文件。IWMHeaderInfo3 接口的相关部分与 IStreamBufferRecordingAttribute 接口几乎相同,因此 AsfMetadataEditor 类和 DvrmsMetadataEditor 类也极其相似。 在这些类就位后,将元数据从一个媒体文件复制到另一个(例如从 DVR-MS 文件复制到经过代码转换的 WMV 文件)就变得极为简单,从而让您保持与经过编码转换的 TV 录制相关联的元数据的保真度: using(MetadataEditor sourceEditor = new DvrmsMetadataEditor(srcPath)){ using(MetadataEditor destEditor = new AsfMetadataEditor(dstPath)) { destEditor.SetAttributes(sourceEditor.GetAttributes()); }} 实际上,正是出于从一个媒体文件向另一个媒体文件复制元数据的目的,我在 MetadataEditor 类中创建了一个静态的 MigrateMetdata 方法,这个方法不仅能按上述方式迁移元数据,而且对它加以扩大,这样在 Media Player 中查看 DVR-MS 文件和在 Media Center 中播放 WMV 文件时,就可以显示更多的可用信息。 返回页首
编辑 DVR-MS 文件除了转换为 WMV 之外,编辑和拼接 DVR-MS 文件可能是我在网上新闻组中看到的第二个最常请求的功能。许多人没有意识到的是,DirectShow RecComp 对象及其 IStreamBufferRecComp 接口提供了现成的拼接功能。IStreamBufferRecComp 接口用于从现有的录制片段创建新的录制,以及将来自一个或多个 DVR-MS 文件的片段连接在一起。 IStreamBufferRecComp 接口非常简单,它的一个 C# 导入如下所示: [ComImport][Guid("9E259A9B-8815-42ae-B09F-221970B154FD")][InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]public interface IStreamBufferRecComp{ void Initialize( [In, MarshalAs(UnmanagedType.LPWStr)] string pszTargetFilename, [In, MarshalAs(UnmanagedType.LPWStr)] string pszSBRecProfileRef); void Append( [In, MarshalAs(UnmanagedType.LPWStr)] string pszSBRecording); void AppendEx( [In, MarshalAs(UnmanagedType.LPWStr)] string pszSBRecording, [In] ulong rtStart, [In] ulong rtStop); uint GetCurrentLength(); void Close(); void Cancel();} 要拼接 DVR-MS 文件,首先要创建 RecComp 对象的实例。这可以通过本文前面介绍的 ClassId.CoCreateInstance 方法来完成,代码如下: IStreamBufferRecComp recCom = (IStreamBufferRecComp)ClassId.CoCreateInstance(ClassId.RecComp)and with ClassId.RecComp defined aspublic static readonly Guid RecComp = new Guid("D682C4BA-A90A-42FE-B9E1-03109849C423"); 有了 IStreamBufferRecComp 之后,就可以使用它的 Initialize 方法来为新的录制指定输出文件名。另外,Initialize 的第二个参数应该是要拼接的其中一个 DVR-MS 输入文件的文件路径。IStreamBufferRecComp 支持连接来自一个或多个文件的片段,但所有这些文件必须使用相同的配置文件录制,这意味着它们必须使用 Media Center 中的相同配置和设置进行录制。RecComp 需要知道输出文件使用什么配置文件,因此您必须指定一个输入文件作为第二个参数,以便它可以检查其配置文件信息并将该信息作为输出文件的基础。 一旦初始化了 IStreamBufferRecComp,您就可以开始构建新文件。调用 Append 方法,指定一个 DVR-MS 输入文件的完整路径,则整个文件就会追加到输出文件中。AppendEx 方法允许您指定附加的开始和停止时间,以便只使用输入文件的一部分并将这部分追加到输出文件中。在非托管接口中,这些时间被定义为 REFERENCE_TIME — 一个代表以 100 毫微秒为单位的数值的 64 位长整数值,所以在托管代码中,您可以使用如下所示的函数来将秒转换成传递给 AppendEx 的 REFERENCE_TIME 值: internal static ulong SecondsToHundredNanoseconds(double seconds){ return (ulong)(seconds * 10000000);} 当您完成追加到输出文件时,Close 方法就会关闭输出文件。在您连接到该文件时,可以使用一个单独线程的 GetCurrentLength 方法来确定输出文件的当前长度。然后您可以使用此信息和您对输入文件/片段长度的了解来计算完成拼接的百分比。请注意,这个过程非常快,因为将片段从一个 DVR-MS 文件追加到另一个文件并不需要编码和解码。 |
||||||||||||||||||||||||||||||||||
-- 作者:admin -- 发布时间:2005-11-26 3:25:00 -- 为了演示此接口,我构建了 DVR-MS 编辑器应用程序(如图 15 所示),并将它作为与本文有关的代码下载的一部分。 图 15. DVR-MS 编辑器 这个应用程序其实非常简单,用了一个多小时就实现了。它使用 Windows Media Player ActiveX 控件来显示输入的视频文件。为了加载视频文件,它将 AxWindowsMediaPlayer.URL 属性设置为 DVR-MS 文件的路径,这样可以使 Media Player 加载该视频(如果 AxWindowsMediaPlayer.settings.autoStart 属性为真,它还会开始播放)。 一旦加载了视频,用户就可以使用“Media Player”工具栏对它进行控制,这个工具栏可以使用户完全控制视频的播放和搜索。当到达用户想要开始或停止一段视频的位置时,就会查询 AxWindowsMediaPlayer.Ctlcontrols.currentPosition 属性。然后,刚才描述的 IStreamBufferRecComp 接口可以使用这些时间来创建输出文件。 另外,Media Player 对视频的当前位置提供了细粒度的编程控制。您可以使用如下所示的代码来逐帧移动视频: ((WMPLib.IWMPControls2)player.Ctlcontrols).step(1); 或者,还可以通过设置刚才讨论的 AxWindowsMediaPlayer.Ctlcontrols.currentPosition 来跳转到视频中的特定位置。 DVR-MS 编辑器应用程序还利用了本文前面描述的一些其他技术,例如将元数据从源视频文件复制到输出视频文件。 返回页首
小结这是令人惊讶的技术,不是吗?DirectShow 和 Windows XP Media Center Edition 团队为开发人员提供了许多处理 DVR-MS 文件的工具(包括非托管代码的和托管代码的)。通过使用这些工具,可以创建新的应用程序来提供大多数人没有意识到他们能够使用的真正强大的功能。本文所讨论的主题只涉及到您可以用来处理 DVR-MS 文件的各种技术的一部分,而在人们编写的使用这些库和工具的解决方案中,它们所占的比例则甚至更小。我期待着获悉您使用这种功能来开发解决方案。 现在,我要回去看会电视了。 返回页首
相关书籍
返回页首
致谢我衷心感谢 Matthijs Gates、Aaron DeYonker、Ryan D/'Aurelio、Ethan Zoller、Eric Gunnerson 和 Alex Seigler 提供他们的研究领域的专家见解,感谢 ABC 允许我使用来自他们电视节目的示例和屏幕快照,也要感谢我的好朋友 John Keefe 和 Eden Riegel,感谢他们允许我在本文中使用他们的肖像。 关于作者 Stephen Toub 是 MSDN Magazine 的技术编辑,他还为该杂志撰写 .NET Matters 专栏。 |
|