DirectShow翻译

1. DirectShow介绍

DirectShow是一个windows平台上的流媒体框架,提供了高质量的多媒体流采集和回放功能。它支持多种多样的媒体文件格式,包括ASF、MPEG、AVI、MP3和WAV文件,同时支持使用WDM驱动或早期的VFW驱动来进行多媒体流的采集。DirectShow整合了其它的DirectX技术,能自动地侦测并使用可利用的音视频硬件加速,也能支持没有硬件加速的系统。

DirectShow大大简化了媒体回放、格式转换和采集工作。但与此同时,它也为用户自定义的解决方案提供了底层流控制框架,从而使用户可以自行创建支持新的文件格式或其它用途的DirectShow组件。

以下是几个使用DirectShow编写的典型应用:DVD播放器、视频编辑应用、AVI到ASF转换器、MP3播放器和数字视频采集应用。

DirectShow是建立在组件对象模型(COM)上的,因此当你编写DirectShow应用时,你必须具备COM客户端程序编写的知识。对于大部分的应用,你不需要实现自己的COM对象,DirectShow提供了大部分你需要的DirectShow组件,但是假如你需要编写自己的DirectShow组件,你还需要具备编写COM组件的知识。

1.1. DirectShow支持的格式

DirectShow是一个开放的框架,因此只要有合适的filter来分析和解码,它可以支持任何格式。DirectShow默认支持以下的文件类型和压缩格式:

注:打*号的需要Windows Media Format SDK支持

文件类型:

Windows Media? Audio (WMA)*

Windows Media? Video (WMV)*

Advanced Systems Format (ASF)*

Motion Picture Experts Group (MPEG)

Audio-Video Interleaved (AVI)

QuickTime (version 2 and lower)

WAV

AIFF

AU

SND

MIDI

压缩格式:

Windows Media Video*

ISO MPEG-4 video version 1.0*

Microsoft MPEG-4 version 3*

Sipro Labs ACELP*

Windows Media Audio*

MPEG Audio Layer-3 (MP3) (decompression only)

Digital Video (DV)

MPEG-1 (decompression only)

MJPEG

Cinepak

微软自己没有提供MPEG2解码器,一些可用的DirectShow MPEG2硬件或软件解码器是由第三方提供的。

1.2. 常见问题集(摘录)

1.2.1. 一般问题

Ø DirectShow支持哪些操作系统?

DirectShow支持Windows9X、Windows2000、Windows Me和Windows XP。

Ø 使用DirectShow需要多少COM知识?

应用程序开发者只需要基本的COM组件知识:实例化COM组件、调用接口、管理接口的引用计数。Filter开发者则需要更多。

Ø 有与DirectShow兼容的硬件列表(HCL)吗?

没有。如果硬件兼容DirectShow,DirectShow会使用它们,如果没有兼容的硬件,DirectShow使用GDI绘制视频,以及使用WaveOut系列多媒体API来播放音频。

Ø 可以使用哪些语言来编写DirectShow应用?

DirectShow主要为C/C++开发设计。Visual Basic只能使用其中的很小一部分。可以通过MS JScript或VB Script来支持基于脚本的DVD和TV应用。也可能用Delphi来编写,但SDK文档不提供这方面的内容。

Ø DirectShow会通过托管代码实现吗?

目前还没有这个计划。DirectX SDK提供了有限的使用音视频回放类的托管回放功能,你可以使用COM interop创建托管代码的DirectShow客户端应用,但是因为性能上的原因,不推荐创建运行在CLR上的filter。

Ø DirectShow开发需要什么样的编译器?

任何能够产生COM对象的编译器都可以。

Ø DirectShow和DirectX的其它组件的关系

DirectShow和DirectX的其它组件在内部进行联系。DirectShow在硬件的支持下使用DirectSound和DirectDraw。Video Renderer和Overlay Mixer使用DirectDraw 3和DirectDraw5表面(surfaces)。Video Mixing Renderer 7(只支持WINXP)使用DirectDraw7表面。Video Mixing Renderer 9使用最新的(目前是Directx9)Direct3D API函数。即便是某个应用程序包含了DirectX其它组件,你也不必使用其它组件的API去编写它。参考SDK的例子:Texture3D Sample。

Ø DirectShow与ActiveMovie的关系?

ActiveMovie是DirectShow原来的名称,现已不再使用,但是一部分API仍保留了"AM"的前缀,比如AM_MEDIA_TYPE和IAMVideoAccelerator。

Ø DirectShow是限于多媒体应用吗?

DirectShow默认包含的组件主要是为音视频流设计的,但是,DirectShow框架已经成功地用于其它数据流的解决方案中。

Ø GraphEdit工具有源码吗?GraphEdit.exe是否可再发布?

没有源码,不可再发布。

Ø DMO可以代替DirectShow filter吗?

在编写编码器、解码器、效果器应用时,鼓励用DMO代替DirectShow filter。在其它的应用中,使用DirectShow filter可能会比较合适。

1.2.2. 程序编写问题

Ø 如何设置编译环境,需要哪些头文件和库?

参考"设置编译环境"章节

Ø GraphEdit列示了很多没有文档支持的filter,它们都是些什么?

GraphEdit枚举了所有作为filter类型注册在系统中的filter,包括由第三方应用程序安装的filter,以及其它微软技术如Windows Media或NetMeeting安装的,另外,一些DirectShow filter被用来做硬编码或硬解码驱动的外壳。Microsoft H.263 Video Codec用于NetMeeting,不再被DirectShow支持。

Ø 如何知道DirectShow已经被安装?

调用CoCreateInstance创建一个Filter Graph Manager实例,如果成功,表示DirectShow已经被安装,下面是一个例子:

IGraphBuilder *pGraph;

HRESULT hr = CoCreateInstance(CLSID_FilterGraph,

NULL, CLSCTX_INPROC_SERVER,

IID_IGraphBuilder, (void **) &pGraph);

Ø 如果不通过属性设置页来更改filter的设置?

当然是通过filter提供的接口罗。如果没有提供,就没有办法啦

Ø DirectShow能通知应用程序当前回放位置吗?

不提供回调来通知位置,需要使用一个计时器定时调用IMediaSeeking::GetCurrentPosition方法来得到当前回放位置。

Ø filter运行在哪个特权级别下?

运行在Ring 3特权级别下,某些流控制驱动(如音视频采集驱动)运行在Ring 0特权级别下。

Ø 需要一个Kernel调试器吗?

这依据具体的项目。安装DirectX调试运行时库(DirectX debug runtime library)意味着安装调试驱动(Debug driver)和其它核心组件(kernel mode component),因此如果你的应用程序在其中的某个组件中产生了一个调试断言(debug assert),你的机器就会自动重启除非你拥有一个kernal调试器。

Ø DEFINE_GUID宏是怎么工作的?

使用DEFINE_GUID宏可以让你通过包含同一个头文件来定义GUID值而不必使用extern关键词。比如,你的工程中有三个源文件:src1.cpp,src2.cpp,src3.cpp,它们都使用一个相同的GUID值,而为了保证一致性,这个GUID只能在你的工程中定义一次,这时,其它的源文件必须定义外部引用来使用它。用了DEFINE_GUID,你可以使用在所有源文件中包含同一个头文件,在头文件中这样定义GUID:

DEFINE_GUID(CLSID_MyObject,

0x00000000, 0x0000, 0x0000, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00);

这个例子中GUID为0,实际编程中请用Guidgen工具来产生一个GUID,在其中的一个源文件中,在你的头文件前包含initguid.h,如:

// Src1.cpp #include

#include "MyGuids.h"

// Src2.cpp

#include "MyGuids.h"

// Src3.cpp

#include "MyGuids.h"

在没有包含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到预编译头文件中去,因为预编译头文件会被每个源文件包含。

2. 开始DirectShow旅程

这个章节的内容主要是编写DirectShow应用所需的一些基本概念,可以把它当作一个高级介绍,理解这些内容只需具备一般的编程和有关多媒体的知识。

2.1. 设置DirectShow开发的编译环境

这节内容描述了如何来编译DirectShow应用。你可以使用命令行形式来编译一个工程,也可以在Microsoft Visual Studio集成环境下(包含VC++)实现。

头文件:

所有的DirectShow应用都需要Dshow.h这个头文件,某些DirectShow接口需要附加的头文件,参考接口的说明视具体情况定。

库文件:

DirectShow使用以下库文件:

Strmiids.lib 输出类标识(CLSID)和接口标识(IID),所有DirectShow应用均需此库。

Quartz.lib 输出AMGetErrorText函数,如果不调用此函数,此库不是必需的。

有了以上这些头文件和库文件,你已经可以编写DirectShow应用了,但是微软建议使用DirectShow基类库来编写filter,这样可以大大减少程序编写的工作量。要使用DirectShow基类库,需要先编译它,基类库位于SDK的Samples//Multimedia//DirectShow//BaseClasses文件夹下,包含两个版本的库:发布版(retail version)Strmbase.lib和调试版(debug version)Strmbasd.lib。具体参见"创建DirectShow Filter"一节。

2.2. DirectShow应用程序编程简介

这节介绍DirectShow用到的一些基本术语和概念,看完这节后,你将能够编写你的第一个DirectShow应用程序。

Filter和Filter Graph

一个DirectShow应用程序是由一个个称为filter的软件构件组合而成的,filter执行一些多媒体流的操作,如:读文件、从视频采集设备中获得视频、将不同的格式的流解码如MPEG1、将数据送到图形卡或声卡中去。

Filter接收输入并产生输出。举个例子,一个解码MPEG1视频流的filter,输入MPEG1格式的视频流,输出一系列未压缩的视频帧。

在DirectShow中,应用程序要实现功能就必须将这些filter链接在一起,因而一个filter的输出就变成了另一个filter的输入。这一系列串在一起的filter称为filter graph。例如,下图就显示了一个播放avi文件的filter graph:

File Source(Async) filter从硬盘中读取avi文件;AVI Splitter filter分析文件并将其分解成两个流:一个压缩的视频流和一个音频流;AVI Decompressor filter将视频帧解码,Video Renderer filter将解码后的视频帧通过DirectDraw或GDI显示出来;Default DirectSound Device filter使用DirectSound播放音频流。

应用程序没有必要对这些数据流进行管理,而是通过一个叫Filter Graph Manager这个上层组件来控制这些filter。应用程序调用上层API如"Run"(通过graph移动数据)或"Stop"(停止移动数据)。如果你需要对数据流作更多的操作,你可以通过COM接口直接进入filter。Filter Graph Manager同样也输出事件通知给应用程序。

Filter Graph的另一个用途是将filter连在一起创建一个filter graph。

编写一个DirectShow应用程序大体需要三个步骤:

1.创建一个Filter Graph Manager的实例

2.使用Filter Graph Manager创建一个filter graph,此时,需要已经具备所有必需的filter。

3.使用Filter Graph Manager控制filter graph和通过这些filter的流,在这个过程中,应用程序会收到Filter Graph Manager发送的事件。

完成这些后,应用程序需发布这个Filter Graph Manager和所有的filter。

2.3. 播放一个文件

这一章以本节这个有趣的例子来结束,这个例子是一个播放音频或视频文件的简单控制台程序。程序只有寥寥数行,但却展示了DirectShow编程的强大能力。

正如上一节所讲的创建DirectShow应用程序的三个步骤,第一步,首先,需要调用CoInitialize来作初始化,然后调用CoCreateInstance创建Filter Graph Manager:

HRESULT hr = CoInitialize(NULL);

if (FAILED(hr))

{

return;

}

IGraphBuilder *pGraph;

HRESULT hr = CoCreateInstance(CLSID_FilterGraph, NULL,

CLSCTX_INPROC_SERVER, IID_IGraphBuilder, (void **)&pGraph);

如上所示,类标识符(CLSID)是CLSID_FilterGraph。Filter Graph Manager由进程内DLL(in-process DLL)提供,因此参数3,dwClsContext的值为CLSCTX_INPROC_SERVER。由于DirectShow运行自由线程模式(free-threading model),所以你同样可以使用COINIT_MULTITHREADED参数来调用CoInitializeEx。

第二步是创建filter graph,调用CoCreateInstance得到的IGraphBuilder接口包含了大部分创建filter graph的方法。在这个例子中还需要另外两个接口:IMediaControl和IMediaEvent。

IMediaControl控制数据流,它包含开启和停止graph的方法;IMediaEvent包含从Filter Graph Manager获取事件的方法,在这个例子中,这个接口用来得到回放结束事件。

所有这些接口由Filter Graph Manager提供,使用得到的IGraphBuiler接口指针来查询得到。

IMediaControl *pControl;

IMediaEvent *pEvent;

hr = pGraph->QueryInterface(IID_IMediaControl, (void **)&pControl);

hr = pGraph->QueryInterface(IID_IMediaEvent, (void **)&pEvent);

现在你可以创建filter graph了,对于文件回放只需要一个简单的调用:

hr = pGraph->RenderFile(L"C:Example.avi", NULL);

IGraphBuilder::RenderFile方法创建了一个能够播放指定文件的filter graph,事实上,原本需要做的一些如创建filter实例及将这些filter连接起来的工作,都由这个方法自动完成了,如果是视频文件,这个filter graph看起来应该是这个样子:

[file source]->[如果是缩格式,这里是个解码器]->[Video Renderer]

要开始回放,调用IMediaControl::Run方法:

hr = pControl->Run();

当filter graph运行时,数据经过各个filter最后回放为视频或音频。回放发生在一个单独的线程中。你可以通过调用IMediaEvent::WaitForCompletion方法来等待回放的结束:

long evCode = 0;

pEvent->WaitForCompletion(INFINITE, &evCode);

这个方法在播放期间被阻塞,直至播放结束或超时。

当应用程序结束时,需要释放接口指针并关闭COM库:

pControl->Release();

pEvent->Release();

pGraph->Release();

CoUninitialize();

下面是这个例子的完整代码:

#include

void main(void)

{

IGraphBuilder *pGraph;

IMediaControl *pMediaControl;

IMediaEvent *pEvent;

CoInitialize(NULL);

// Create the filter graph manager and query for interfaces.

CoCreateInstance(CLSID_FilterGraph, NULL, CLSCTX_INPROC_SERVER,

IID_IGraphBuilder, (void **)&pGraph);

pGraph->QueryInterface(IID_IMediaControl, (void **)&pMediaControl);

pGraph->QueryInterface(IID_IMediaEvent, (void **)&pEvent);

// Build the graph. IMPORTANT: Change string to a file on your system.

pGraph->RenderFile(L"C://Example.avi", NULL);

// Run the graph.

pMediaControl->Run();

// Wait for completion.

long evCode;

pEvent->WaitForCompletion(INFINITE, &evCode);

// Clean up.

pMediaControl->Release();

pEvent->Release();

pGraph->Release();

CoUninitialize();

}

3.1.DirectShow体系概述

多媒体的难题

处理多媒体有几个主要的难题:

*多媒体流包含了巨大的数据量,而这些数据都必须非常快地被处理

*音频和视频必须同步,因此它们必须在同一时间开始或停止,并以同一速率播放

*数据可能来自很多的源,如本地文件、网络、电视广播和视频摄像机

*数据有各种各样的格式,如AVI、ASF、MPEG和DV

*程序员无法预知最终用户使用什么样的硬件设备

DirectShow的解决方案

DirectShow被设计成用来解决所有这些难题,它主要的设计目的就是通过将复杂的数据转输、硬件的多样性和同步问题从应用程序中独立出来,从而简化在windows平台上数字媒体应用程序的开发任务。

要实现数据高效地被处理,需要流化音视频数据,而DirectShow会尽可能地使用DirectDraw和DirectSound,从而高效地将数据送到用户的声音和图形设备中进行播放。同步则是通过在媒体数据中加入时间戳来实现。而DirectShow模块化的架构,使其可以轻松操纵变化多端的源、格式和硬件设备,在这样的架构里,应用程序只需组合和匹配多个filter来实现功能。

DirectShow提供的filter支持基于WDM的采集和调谐设备,也支持早先的VFW采集卡和为ACM和VCM接口编写的编码器。

下图显示了应用程序、DirectShow组件和DirectShow支持的硬件和软件组件之间的关系:

clip_image001 如图,DirectShow将应用程序与众多复杂的设备隔离开来,通信和控制这些设备均出DirectShow的filter来完成。DirectShow同样为某种文件格式提供与之对应的编解码器。

3.5 DirectShow中的事件通告

这一节主要描述在directshow filter graph中事件是怎样发生的,以及应用程序如何接收事件通告并响应它们。

3.5.1 概述

一个filter通过发送一个事件通来通知filter graph manager某个事件已经发生。这些事件可以是一些预知的事件比如流结束事件,也可以是一些异常如render流时失败。一部分事件由filter graph manager自己处理,另一部分则由应用程序来处理。如果filter graph manager不处理某个事件,那么这个事件会被放入到队列中去。filter graph也可以通过队列将自己的事件发送给应用程序。

应用程序从队列中接收事件并根据其类型来响应它们。DirectShow中的事件通告类似于windows的消息队列机制。应用程序可以让filter graph manager取消对指定的事件类型的默认操作,而是将它们放入事件队列由应用程序来处理它们。

由于这样的机制,使我们能做到:

*filter graph manager与应用程序的对话

*filter可以即和应用程序也和filter graph manager对话

*由应用程序来决定处理事件的复杂度。

3.5.2 从队列中取事件

Filter Graph Manager暴露3个支持事件通知的接口:

*IMediaEventSink 包含filter发送事件的方法

*IMediaEvent 包含应用程序取事件的方法

*IMediaEventEx 继承扩展IMediaEvent接口

filter通过在filter graph manager上调用IMediaEventSink::Notify方法来发送事件通告,一个事件通知由一个表示事件类型的事件号,和两个DWORD类型用以放置附加信息的参数组成。按事件号的不同,这两个参数可以是指针、返回值、参考时间或者其它信息。完整的事件号和参数列表,参见Event Notification codes (http://msdn.microsoft.com/library/en-us/directshow/htm/eventnotificationcodes.asp)。

要从事件队列中取事件,应用程序需要在filter graph manager上调用IMediaEvent::GetEvent事件。这个方法一直阻塞到取到事件或超时。一旦队列中有了事件,这个方法就返回事件号和两个事件参数。在调用GetEvent后,应用程序应该总是调用IMediaEvent::FreeEventParams方法来释放与事件参数相关的所有资源。比如,一个参数可能是由filter graph分配的BSTR值。

下面的代码是一个如何从队列中取事件的框架:

long evCode, param1, param2;

HRESULT hr;

while (hr = pEvent->GetEvent(&evCode, &param1, &param2, 0), SUCCEEDED(hr))

{

switch(evCode)

{

// Call application-defined functions for each

// type of event that you want to handle.

}

hr = pEvent->FreeEventParams(evCode, param1, param2);

}

要重置filter graph manager默认的事件处理过程,调用IMediaEvent::CancelDefaultHandling方法,用事件号做参数。你可以通过调用IMediaEvent::RestoreDefaultHandling方法来恢复某个事件的处理过程。如果filter graph对某个事件号没有默认处理过程,则调用上面两个方法不产生任何影响。

3.5.3 当事件发生时

要处理DirectShow事件,应用程序需要一个方法来知道事件何时正等待在队列中。Filter Graph Manager提供两种方法:

*窗口通告:一旦有事件发生,Filter Graph Manager就发送一个用户自定义窗口消息来通知应用程序窗口

*事件信号:如果有DirectShow事件在队列中,filter graph manager就触发一个windows事件,如果队列为空,则reset这个事件。

应用程序可以使用任何一种方法,但通常窗口通告方法相对比较简单。

窗口通告:

要设置窗口通告,调用IMediaEventEx::SetNotifyWindow方法并指定一个私有消息,私有消息可以是从WM_APP到0xBFFF的任一个。一旦filter graph manager把一个新的事件通告放入队列中,它便发送这个消息给指定的窗口。应用程序从窗口的消息循环中来响应这个消息。

下面是如何设置通知窗口的例子:

#define WM_GRAPHNOTIFY WM_APP + 1 // Private message.
pEvent->SetNotifyWindow((OAHWND)g_hwnd, WM_GRAPHNOTIFY, 0);

消息是一个普通的windows消息,并且独立于DirectShow消息通告队列被发送。使用这种方法的好处是大部分应用程序拥有一个消息循环,因此,要知道DirectShow事件何时发生便无需做额外的工作了。

下面是一段如何响应通告消息的框架代码:

LRESULT CALLBACK WindowProc( HWND hwnd, UINT msg, UINT wParam, LONG lParam)

{

switch (msg)

{

case WM_GRAPHNOTIFY:

HandleEvent(); // Application-defined function.

break;

// Handle other Windows messages here too.

}

return (DefWindowProc(hwnd, msg, wParam, lParam));

}

因为事件通告与消息循环均为异步进行的,因此在应用程序响应事件时队列中可以会有多个事件。而当事件变为非法时,它们会从队列中被清除掉。所以在你的事件处理代码中,调用GetEvent直至返回一个表示队列已空的失败代号。

在释放IMediaEventEx指针前,请以NULL作参数调用SetNotifyWindow方法来取消事件通告。并且在你的事件处理代码中,在调用GetEvent前检查IMediaEventEx指针是否合法。这些步骤可以防止在释放IMediaEventEx指针后应用程序继续接收事件通告的错误。

事件信号:

Filter Graph Manager建立一个反映事件队列状态的手工重设事件(manual-reset event)。如果队列中包含有未处理的事件通告,Filter Graph Manager就会发信号给手工重设事件。如果队列是空的,则调用IMediaEvent::GetEvent方法会重设(reset)事件。应用程序可以通过这个事件来确定队列的状态。

注意:此处的术语可能被混淆。手工重设事件是由windows的CreateEvent函数创建的一种事件类型,它与由DirectShow定义的事件无关。

调用IMediaEvent::GetEventHandle方法得到手工重设事件的句柄,调用一个函数如WaitForMultipleObjects来等待发送给手工重设事件的信号。一旦收到信号,就可以调用IMediaEvent::GetEvent来接收DirectShow事件了。

下面的代码举例说明了这种方法。在取得事件句柄后,在100毫秒时间间隔内等待发送给手工重设事件的信号,如果有信号发来,它调用GetEvent然后在windows控制台上打印出事件号和事件参数,循环在EC_COMPLETE事件发生后结束,这标志着回放结束。

HANDLE hEvent;

long evCode, param1, param2;

BOOLEAN bDone = FALSE;

HRESULT hr = S_OK;

hr = pEvent->GetEventHandle((OAEVENT*)&hEvent);

while(!bDone)

{

if (WAIT_OBJECT_0 == WaitForSingleObject(hEvent, 100))

{

while (hr = pEvent->GetEvent(&evCode, &param1, &param2, 0), SUCCEEDED(hr))

{

printf("Event code: %#04x/n Params: %d, %d/n", evCode, param1, param2);

hr = pEvent->FreeEventParams(evCode, param1, param2);

bDone = (EC_COMPLETE == evCode);

}

}

}

因为Filter Graph会在适当的时候自动重设事件,因此你的应用程序应当不去作重设工作。同时,当你释放filter graph时,filter graph会关闭事件句柄,因此在这之后你就不能再使用事件句柄了。

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与输入的音频数据速率进行匹配。

3.7. Graph动态重建(Dynamic Graph Building)

如果你需要修改一个已经存在的filter graph,你可以停止,修改后再重新启动它。这通常是一种最佳的解决方法。但是,在某此情况下,你可能需要在一个graph处于运行状态时来修改它,比如:
*应用程序在进行视频回放时需要插入一个(视频滤镜filter)Video effect filter;
*source filter在播放的过程中改变了媒体格式,此时可能需要接入新的解码filter;
*应用程序在graph中加入一个新的视频流。
上面的这些都是graph动态重建的例子。所有在graph继续处于运行状态而做的graph修改都被叫做graph动态重建。动态重建可以由应用程序发起,也可以由一个在graph中的filter发起。动态重建有三种可能:
*媒体格式动态变化:一个filter可以在运行的中途改变媒体格式,而不需要重新被替换为另一个;
*动态重连:在graph中添加或删除filter
*Filter Chain操作:添加,删除,控制filter chain,(Filter Chain是相互连接着的一条Filter链路,并且链路中的每个Filter至多有一个Input pin,至多有一个Output pin)

3.7.1. 动态重连

在绝大多数的directshow filter中,当graph处于运行状态时pin是不能被重新连接的,应用程序必须在重连前停止graph。但是,某些filter却支持动态重连,这既可以由应用程序来执行,也可以由graph中的一个filter来执行。
如下图:

clip_image002

假设我们要将filter 2从graph中移除掉,替换成另一个filter,而此时graph还处于运行状态,那么必须具备以下几个条件:

*filter 3的输入pin(pin D)必须支持IPinConnection接口,这个接口可以重新连接pin而不需要停止它。

*filter 1的输出pin(pin A)必须能够在重连时阻塞媒体数据,数据不再在pin A和pin D之间传递。也就是说,输出Pin必须支持IPinFlowControl接口。但是,如果filter 1是发起重连的那个filter,那么它有可能已经在其内部实现了阻塞;

动态重连包括下列步骤:

1. 从Pin A那里阻塞数据流

2. 重新连接Pin A和Pin D,或者在中间加入新的filter

3. 取消Pin A上的阻塞

步骤1. 阻塞数据流通过调用Pin A上的IPinFlowControl::Block方法来阻塞数据流。这个方法既可以被同步调用,也可以被异步调用。要异步调用这个方法,需要创建一个win32事件对象,并将事件句柄传给Block,方法会立即返回,然后使用WaitForSingleObject或其它函数来等待事件的触发。当阻塞工作完成时,pin会触发这个事件。如:

HANDLE hEvent = CreateEvent(NULL, FALSE, FALSE, NULL);

pFlowControl->Block(AM_PIN_FLOW_CONTROL_BLOCK, hEvent);

WaitForSingleObject(hEvent, dwMilliseconds);

如果是同步调用Block,那么只需将传入的hEvent参数设为NULL,此时这个方法会一直阻塞到阻塞工作完成为止。如果pin还没有准备好deliver一个新的sample,那么就会一直阻塞。而如果filter处于就绪状态,这可能会花费任意长的时间,因此,不要在应用程序的主线程中使用同步调用,以免发生死锁,开一个工作线程来使用同步调用,或者干脆就使用异步调用。

步骤2. 重连pin

要重新连接pin,查询graph的IGraphConfig接口并调用IGraphConfig::Reconnect或IGraphConfig::Reconfigure。Reconnect方法使用比较简单:

*停止中间filter(比如filter 2),并移除它

*如果需要的话,加入新的中间filter

*连接所有的pin

*pause或run所有新的filter,使它的状态与graph相同

Reconnect方法有参数可以用来指定pin连接的媒体类型和中间filter。如:

pGraph->AddFilter(pNewFilter, L"New Filter for the Graph");
pConfig->Reconnect(
pPinA,// Reconnect this output pin...
pPinD,// ... to this input pin.
pMediaType, // Use this media type.
pNewFilter, // Connect them through this filter.
NULL,
0);

如果Reconnect还不够用来应付我们的要求,那么你可以使用Reconfigure方法,它调用一个由应用程序定义的回调函数来重连这些pin。要调用这个方法,需要在你的应用程序中实现IGraphConfigCallback接口。

在调用Reconfigure之前,如前面所述地那样阻塞输出pin的数据流。然后如下所示,将处于待处理状态的数据push下去:

1. 在重连链路中处于下游的最远的那个输入pin(例子中为Pin D)上调用IPinConnection::NotifyEndOfStream方法,方法的参数是一个Win32事件句柄;

2. 在与要阻塞数据的那个输出pin直接相连的那个输入pin上调用IPin::EndOfStream方法。(在例子中,要阻塞的那个输出pin是pin A,那么直接与之相连的那个输入pin为Pin B);

3. 等待事件触发。输入pin(pin D)在它接收到end-of-stream事件通告时触发事件。这表示再没有数据需要传输,此时就可以安全地进行重连了。

注意:IGraphConfig::Reconnect方法会自动处理上述步骤,你仅在调用Reconfigure方法时才需要自己来处理。

当数据完成push后,调用Reconfigure,传入IGraphConfigCallback回调函数的指针。Filter Graph Manager会调用IGraphConfigCallback::Reconfigure方法。

步骤3. 取消数据流的阻塞

当你完成重连后,通过调用IPinFlowControl::Block,第一个参数为0来取消阻塞。

注意:如果动态重连是由一个filter来执行的,那么你需要知道一点线程方面的问题。如果filter graph manager尝试去停止filter,它可能会死锁,因为graph等待filter停止,而与此同时,filter有可能在等待数据在graph中完成push。要防止这个可能存在的死锁问题,如前所述可以用事件机制来处理。

3.7.2. filter链(filter chains)

一个filter chain是一系列具备下述条件的相互连接的filter:

*每一个在链中的filter最多只有一个已连接的输入pin和一个已连接的输出pin;

*Filter链路中的数据流不依赖于链路外的其他Filter

举个例子,在下图中,filter A-B,C-D和F-G-H是一个filter chains。每个F-G-H中的子链(F-G和G-H)也是一个filter chain。一个filter chain同样可以是由单个filter组成的,因此A、B、C、D、F、G和H同样也是filter chain。filter E由于有两个输入连接,所以任何含有E的一系列filter都不是filter chain。

clip_image003

IFilterChain接口提供下述方法来控制filter chain:

IFilterChain::StartChain开启一个链

IFilterChain::StopChain 停止一个链

IFilterChain::PauseChain暂停一个链

IFilterChain::RemoveChain从graph中移除一个链

没有特殊的方法来添加一个链,要添加链,通过调用IFilterGraph::AddFilter方法来插入新的filter,然后调用IGraphBuilder::Connect,IGraphBuilder::Render或类似的方法来连接它们。

当graph运行时,一个filter chain可以在运行和停止状态间切换。当graph处理就绪状态时,它可以在就绪和停止状态间切换。这是两种仅有的filter chain状态切换可能。
Filter链指南

当你使用IFilterChain方法时,确认在graph中的filter是否能支持filter链操作是十分必要的,否则,可能会发生死锁或graph错误。filter连接到链上必须发生在链状态改变后。

使用IFilterChain的最佳情况是与一系统为链而设计的filter一起使用。使用下面的指南来确保你的filter是链操作安全的。参考下图:

clip_image004

*在filter链状态变化前,所在在filter链分界线上调用的数据处理都必须已完成。这个规则应用于IMemInputPin::Receive、IPin::NewSeqment和IPin::EndOfStream方法。链中的filter必须从由链外filter实现的这些方法调用中返回;而链外的filter也必须从这些由链内filter实现的这些方法调用中返回。

举个例子,在上图中,filter B必须完成在filter A上的所有数据处理调用,而filter E也必须完成从filter D上的调用。如果pin暴露了IPinFlowControl和IPinConnection接口,那么如在动态重连那一节中所讲的,你可以通过调用IPinFlowControl::Block和IGraphConfig::PushThroughData方法来推数据。filter也可能通过自己的方法来推数据。

*上游filter必须与链的状态一起发生变化。比如,在上图中,假如链已停止,但filter A调用IMemInputPin::Receive方法,那么调用将失败,作为回应,filter A停止流。当应用程序重新开启链时,不会产生什么影响,因为filter A不再向使数据流动了。

*下游filter必须同样与链的状态一起发生变化,否则,下游filter在等待取得sample时会发生死锁,因为sample不会再到来了。比如,多路复用(MUX)filter总是在它所有的input pin上需要数据,如果挂起其中的一个input pin,在其它input pin上的流处理也会被阻塞。这会导致graph死锁

*每个与链内部filter相连的外部filter的pin必须拥有自己的分配器(allocator),它不能被其它pin连接共享。当链的状态发生变化或从graph移除掉时,分配器便不可用了,此时如果还有其它的连接使用这个分配器的话,它们将不能再处理sample了。

*除非与链相连的filter支持动态断开,否则不要移除链。典型的,已连接的filter会支持IPinConnection或IPinFlowControl接口,或者用它自己定义的接DirectShow。

3.8. 插件发布者(Plug-in Distributors)

Plug-in Distributors(PIDs)是扩展filter graph manager的一种方法。一个PID是filter graph manager在运行时聚合的一个COM对象。应用程序通过filter graph manager来进入PID。

当filter graph manager被要求查询一个它不支持的接口时,它会搜索注册表项:

HKEY_CLASSES_ROOT//Interface//IID//Distributor

IID是接口的GUID,如果注册项存在,那么键值便是支持该接口的PID类标识(CLSID)。filter graph manager聚合了PID并返回接口指针,应用程序调用这个指针时实际上就是在调用PID,但是这对于应用程序来说是透明的,对于应用程序来说,它就象是在filter graph manager上调用这个接口一样。

PID为应用程序提供了一种简单的控制filter的方法,如通过调用IFilterGraph::EnumFilters方法,PID可以枚举graph中的所有filter并调用这些filter上的方法。

当filter graph manager聚合了一个PID时,它查询PID的IDistributorNotify接口,如果PID支持这个接口,filter graph manager用它来通知PID有关graph的状态变化:

* 当filter graph在run、pause和stop状态之间切换时,它调用IDistributorNotify::Run, IDistributorNotify::Pause或IDistributorNotify::Stop。

* 如果调置了参考时钟,filter graph manager调用IDistributorNotify::SetSyncSource。

* 当有filter添加或移除,或pin连接有变化时,filter graph manager调用IDistributorNotify::NotifyGraphChange。

当自己定制PID时,自己所创建的COM对象必须支持聚合,并且它所支持的接口是filter graph manager本身所没有的。IDistributorNotify接口是可选的。

如果PID从filter graph manager上获得一个接口,那它必须立即release这个接口,否则会在COM对象上出现循环引用的参考计数,使得filter graph manager无法被销毁。在filter graph manager上保持一个参考计数是多余的,因为PID的生命期是由filter graph manager控制的。

因为PID是明确指定是被filter graph manager聚合使用的,因此你应该在PID的构造函数中强行检查IUnknown指针是否为NULL,如果为NULL,则返回错误码VFW_E_NEED_OWNER。同时,为了防止其它对象聚合PID,你可以在IUnknown上查询IGraphBuilder接口,如果不行则返回错误。

--奇妙”的Merit(玩死Media Player)

运行GraphEdit,插入Filter,我们可以看到:每个Filter的信息一般包括Displayname、Filename、Merit、各个Pin以及Pin支持的Mediatype,还有Version等。我们今天就来看一看这个Merit(其他的一些Filter信息大家从它的名字上就可以猜到它的意义)。

要说Merit,肯定要先说Filter Graph Manager使用的智能连接(Intelligent Connect)机制。我们在Filter Graph中Render一个Pin,或者Render一个File,然后看到一条自动的“解码”Filter链路就完成了——这就是智能连接机制。执行这个机制的调用方法为:IGraphBuilder::RenderFile, IGraphBuilder::Render, 和 IGraphBuilder::Connect。下面分别对这三个调用方法进行阐述。

RenderFile:给出一个文件名,首先要找到正确的Source Filter。Filter Graph Manager通过查找注册表来决定使用什么Source Filter。在注册表中,一般会有文件扩展名或者特征字节与使用的Source Filter的对应信息。找到Source Filter之后,就从该Source Filter的各个Output pin开始,进行剩下的职能连接过程。这是一个“递归”过程,直到所有的分支都连到一个Renderer Filter上。步骤大致为:

1. 如果Output pin支持IStreamBuilder接口,则把剩下的工作交给IStreamBuilder::Render。

2. 使用在内存中缓冲的Filter进行是试连接。

3. 使用在当前Filter Graph中还没有完全连接的Filter进行试连接。(如果你想智能连接使用特定的你想使用的Filter,一种方法是,在开始智能连接之前先把该Filter加入到Filter Graph中。)

4. 使用IFilterMapper2::EnumMatchingFilters搜索注册表。Filter Graph Manager使用Merit值大于MERIT_DO_NOT_USE的所有Filter(Filter所在的目录Merit值也应该大于MERIT_DO_NOT_USE)进行试连接。在匹配Mediatype的前提下,Merit值越高,该Filter被使用的概率越高。

Render:这个方法从当前Filter Graph的某个Filter的指定Output pin开始,进行从这个Pin往下的一条支路的智能连接。智能连接的算法与上述RenderFile的类似。

Connect:这个方法调用,以欲连接的一对Input pin和Output pin作为参数。首先进行这两个Pin之间的直接连接。如果不能成功连接,则要插入“中介”Filter。这个“中介”Filter的插入过程就是一个智能连接过程,算法与上述的RenderFile类似。

现在我们知道了智能连接是怎么回事。DirectShow的这个机制,很“聪明”,可以方便地使用第三方(非Microsoft公司)开发的Filter。但是,有一个问题,就是如果系统中存在一些“恶意”的Filter,那么这个智能连接机制就会受到严峻的考验。因为这个原因,基于智能连接机制的应用程序(比如Windows Media Player)也会变得不稳定。(笔者并不赞成直接使用DirectShow Editing Services API进行非线性编辑,就是出于这方面的考虑。)

大家可以下载我写的这个测试Filter源代码(http://hqtech.nease.net/Document.htm)。其实这是一个CTransFormFilter的空架子,只是这个Filter的Merit值非常高(0x8800000),而且支持所有的Mediatype。注册这个Filter后,当有DirectShow应用程序使用智能连接机制时,就会反反复复地使用这个Filter进行试连接,没有休止。如果你使用Windows Media Player播放媒体文件(AVI、MPEG、WMV等等),应用程序就会阻塞住;即使是RealOne Player,在播放微软格式的文件时也会出现这种现象。

好了,不玩了,别把Media Player弄得太惨,毕竟对我们也没什么好处!:)期望大家已经对这个Merit有了更深的认识。接下去,把这个Filter从你的系统注销吧:regsvr32 /u yourlocalpath//HQMPKiller.ax。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值