用 C# 编写 DirectShow 解复用器(Demultiplexors)。第 1 部分 - Windows Media 分流器示例。





5.00/5 (10投票s)
本文介绍了开发自己的 DirectShow 分流器(Splitter)过滤器所需完成的基本任务。

引言
让我们继续探索 DirectShow 的世界。在这里,我将介绍如何开发解复用器。我将详细阐述构建这类过滤器所需的各个方面,并基于我在文章《Pure .NET DirectShow Filters in C#》中描述的 C# 类库 BaseClasses.NET 提供一个示例和代码概述。这是第一部分,我将仅回顾带有输入引脚的分流器过滤器,第二部分将回顾源过滤器。
背景
DirectShow 分流器(也称为解复用器)的主要目的是处理媒体文件格式;解析文件并提供上游过滤器可以理解和处理的独立数据流。我认为听起来不难。让我们看看 GraphEdit 截图。

在这里,我们看到了系统自带的标准 AVI 分流器。分流器有一个输入引脚,该引脚连接到加载了文件的“File Source Async”过滤器。这里我们有两个输出引脚,因为文件包含音频和视频数据。一旦输入引脚连接,这些引脚就会自动创建,并在断开连接时移除。一旦图表开始播放,分流器就会在输入引脚上读取或接收数据,进行解析,并将其传递到指定的输出引脚。让我们看看它是如何工作的。
FilterGraph 如何决定使用哪个分流器?
首先,尝试启动 GraphEdit 应用程序并渲染任何媒体文件。对于不同的文件类型,过滤器图会选择不同的分流器。如果系统中没有为指定文件类型注册分流器,那么你就无法使用 DirectShow 播放该文件。更重要的是,如果你手动加载文件并使用“File Source Async”过滤器,它可以为不同的文件显示不同的输出类型。例如,在上面的 Graph 中,它显示了 Major Type“Stream”和 Subtype“Avi”,如果我加载一个 mpeg1 PES 文件,它可以显示“MPEG1System”子类型。这是因为系统中注册了媒体类型。你可以创建自己的文件格式并注册它,这样“File Source Async”过滤器就会显示你的类型 GUID。为此,只需在过滤器注册期间将注册信息写入系统注册表即可。**注意**:在这篇文章中,我只回顾分流器过滤器的注册,源过滤器的注册将在第二部分描述。媒体类型在以下注册表子项中注册:
HKCR\Media Type\{E436EB83-524F-11CE-9F53-0020AF0BA770}\{ Subtype GUID }
GUID 是作为输出提供的 major type,通常是 `MEDIATYPE_Stream`。Subtype GUID 是你的自定义类型 GUID。这里的注册表值描述了如何识别你的文件类型,以及使用哪个源过滤器来处理它。源过滤器是表示应该为该文件类型选择的过滤器的 GUID 的字符串值;在大多数情况下,它是“File Source Async”。其他注册表值以“0”开头。至少应该有一个值,否则你的媒体类型将无法被检测到。该值是通过检查字节序列来帮助源过滤器识别你的类型的。该序列是一个字符串值,由整数组成:“offset, length, mask, value”,用“,”分隔。Filter Graph 在文件渲染期间,从文件位置指定的“offset”读取“length”指定的字节数,然后与“mask”进行按位 AND 操作,并将结果与“value”进行比较,如果结果匹配,则加载源过滤器并设置其中的媒体类型。**注意**:在一个字符串中可以有多个 offset, length, mask, value 条目,以防有多个条目。Filter Graph 对单个行中指定的所有条目执行 AND 操作。注册表的外观可以在下一个屏幕截图中看到。

在 BaseClassesNET 库中,我创建了指定的属性,可以指定在你的分流器过滤器或解析器类上,你的媒体类型将被注册。
[ComVisible(false)]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RegisterMediaType : Attribute
属性允许进行以下构造修改:
// Guid of the subtype and the sequence
public RegisterMediaType(string _subtype, string _sequence)
// Guid of the source filter, Guid of the subtype and the sequence
public RegisterMediaType(string _filter, string _subtype, string _sequence)
// Major type Guid, Guid of the source filter, Guid of the subtype and the sequence
public RegisterMediaType(string _filter, string _majortype, string _subtype, string _sequence)
一旦文件源加载并且媒体类型设置完毕,Filter Graph 就可以与之连接。在手动加载源过滤器并连接过滤器的情况下,则不需要此类注册。
基本过滤器架构
正如我们已经讨论过的,分流器从输入引脚获取数据,并通过输出引脚将其提供给上游过滤器。在每个输出引脚上应该有一个自己的线程,因为我们必须将文件中的数据分开并单独传递。如果文件中有索引或其他允许我们更快访问其内容的内容,我们可能不需要为数据分流创建单独的线程。在这种情况下,数据将在每个引脚线程中单独读取。否则,过滤器中将有一个通用的第三个线程,该线程负责读取数据,将其分成单独的块(对于视频通常称为帧,对于音频称为包),并将它们添加到每个输出引脚的队列中。同时,该引脚从队列中读取该块并将其传递给上游过滤器。在本文中,我们将回顾第二个模型,即使用通用的解析器线程。该模型看起来像这样:

我认为不复杂,对吧?最初,过滤器有一个输入引脚。一旦 Filter Graph Manager 连接到它,我们就会在过滤器内部执行验证 - 我们是否可以处理来自已连接过滤器的数据。如果连接到“File Source Async”,我们使用 `IAsyncReader` 接口和 `SyncRead` 或 `SyncReadAligned` 方法。我们可以从已连接的引脚查询接口。使用这两个方法,我们可以在任何位置读取流中的数据并检查给定的数据格式。如果数据不适合我们,我们只需以指定的错误代码丢弃连接。否则,我们应该读取文件,直到我们定义所有可用的流(例如,2 个流:音频和视频),然后为每个流创建一个输出引脚。准备好我们可以处理的输出媒体类型。我们可以通过在名称前加上“~”字符来命名非强制性引脚。在这种情况下,Filter Graph Manager 会在自动渲染时忽略该引脚,但你仍然可以手动渲染它。例如,这对于字幕、其他可能不重要的事情是必需的。此外,Filter Graph Manager 会尝试渲染所有名称不以“~”开头的输出引脚,如果其中一个无法连接,则过滤器将被丢弃,Graph Manager 会寻找其他过滤器。
随着这个模型,我们还需要处理分流器和文件源的主要 DirectShow 任务。
分流器的主要任务
你可能会想:“分流器还需要什么?”,实际上有很多。在这里,我回顾了主要任务,一些其他附加内容将在下一篇文章中描述。
跳转
众所周知,在每个媒体播放器应用程序中,我们都有跳转功能,可以在其中设置播放位置。这个功能完全由分流器准备好。此外,分流器提供当前媒体文件的持续时间信息。分流器负责根据播放速率对时间戳进行处理。分流器不提供当前播放位置 - 它提供开始和停止位置,但不是当前位置。当前位置设置在渲染器中,在应用程序中,你看到的位置不是所有流(输出引脚)的位置,而只有一个,通常是音频(这是因为音频流始终是播放的首选时钟提供者)。但在我们的过滤器中,每个输出引脚都应该实现 `IMediaSeeking` 接口。那么在我们的过滤器中如何决定使用哪个引脚进行跳转呢?实际上,你可以选择任何已连接的引脚,最好是具有更高媒体流持续时间的引脚。
新段
一旦引脚变为活动状态,它应该通知上游过滤器,下一个媒体样本将位于同一段中。这意味着什么?例如,我们开始播放文件,在传递任何样本之前,我们发送带有开始和停止位置以及当前播放速率的新段通知。如果你在开始播放之前没有调用 SetPositions 或 SetRate,那么你将收到带有开始、停止和速率值为 0、`MAXLONGLONG`、1.0 的新段通知。一旦你跳转到图表,你就设置了新的开始位置,或者你改变了停止位置,或者你改变了速率,在执行任何跳转操作后,你都会收到带有新设置的新段通知。
刷新
刷新意味着过滤器应该丢弃任何接收到的样本,并执行清理工作,以便准备好接收新数据。换句话说,在解码器中,我收集引用的数据以构建完整的帧以输出,一旦我收到 `BeginFlush` 通知,我就会清除引用的数据,并丢弃任何传入的样本,直到我收到 `EndFlush`。希望现在更容易理解了。`BeginFlush` 和 `EndFlush` 通知也由分流器发送。这是由于跳转引起的。一旦用户设置了新的位置,我们就向所有引脚发送 begin flush,然后停止所有线程(引脚和解复用器),然后发送 end flush,这样上游过滤器就可以准备好接收来自新位置的数据。
流结束
一旦分流器没有该流的数据,它就会发送 `EndOfStream` 通知。这对已连接的过滤器意味着不要期望来自该引脚的任何数据。并且通过该引脚发送的任何数据都应该被丢弃。情况:我们有一个包含 2 个持续时间不同的流的文件,例如 10 秒和 5 秒,在播放 7 秒后,你跳转到 2 秒的位置,在持续时间为 5 秒的流的引脚上会显示什么?答案是 - 是的。它将这样工作:跳转后,由于我们在该引脚上收到了 EndOfStream 通知,我们不刷新数据,因为本应没有数据,但我们更改了位置并发送了另一个通知 - `NewSegment`,并通知上游数据可用。
Windows Media 分流器过滤器实现
在上一篇文章中,我描述了用于创建分流器过滤器的类,我还添加了一个基本示例。在这里,我将回顾使用 WMF SDK 实现 Windows Media 文件分流器。该类库提供了创建此类过滤器的绝佳机会,过滤器本身看起来非常简单。
[ComVisible(true)]
[Guid("A7EABBF9-F348-4879-9AB9-29A0B0F80CF4")]
[AMovieSetup(true)]
[PropPageSetup(typeof(AboutForm))]
public class WMSplitterFilter : BaseSplitterFilterTemplate<WMParser>
{
public WMSplitterFilter()
: base("CSharp WM Splitter")
{
}
}
实际上,我们只专注于实现文件格式支持的东西,所有其他东西已经在基类中实现了。所以我们需要基于 `FileParser` 和 `DemuxTrack` 类实现我们自己的类,这些类在上一篇文章中已经描述过。
实现解析器
我们的解析器的主要任务是:
- 检查输入文件 - 我们是否可以播放它。
- 初始化轨道。
- 解复用数据并将其放入轨道队列。
- 支持跳转。
打开文件
首先,我们应该通过调用 `WMCreateReader` API 来创建一个读取器对象。然后,我们应该从 `IWMReader` 对象查询 `IWMReaderAdwanced2` 接口。`IWMReaderAdvanced2` 暴露了 `OpenStream` 方法,我们应该调用它来打开媒体数据。正如你所见,该方法需要 3 个参数:`IStream` 对象 - 我们将使用打开的 `BitStreamReader`,因为它支持该接口;`IWMReaderCallback` - 用于异步处理状态通知的接口,我们将在读取器类中实现它;第三个参数 - 上下文数据 - 我们不使用。一旦我们调用该方法,流将异步打开并将状态结果发送到回调,因此我们只需提供一个事件等待机制。
protected override HRESULT CheckFile()
{
HRESULT hr = (HRESULT)WMCreateReader(IntPtr.Zero, 0, out m_pWMReader);
if (SUCCEEDED(hr) && m_pWMReader != IntPtr.Zero)
{
IWMReaderAdvanced2 pReaderAdvanced = (IWMReaderAdvanced2)Marshal.GetObjectForIUnknown(m_pWMReader);
m_evAsyncNotify.Reset();
hr = (HRESULT)pReaderAdvanced.OpenStream(m_Stream, this, IntPtr.Zero);
if (SUCCEEDED(hr))
{
m_evAsyncNotify.WaitOne();
hr = m_hrAsyncResult;
}
if (SUCCEEDED(hr))
{
pReaderAdvanced.SetUserProvidedClock(true);
}
pReaderAdvanced = null;
}
return hr;
}
在回调的 `OnStatus` 方法中;在这里,我们只是设置异步事件并设置结果变量。
public int OnStatus(Status Status, int hr, AttrDataType dwType, IntPtr pValue, IntPtr pvContext)
{
switch (Status)
{
case Status.Opened:
m_hrAsyncResult = (HRESULT)hr;
m_evAsyncNotify.Set();
break;
case Status.Stopped:
m_hrAsyncResult = (HRESULT)hr;
m_evAsyncNotify.Set();
break;
case Status.Closed:
m_hrAsyncResult = (HRESULT)hr;
m_evAsyncNotify.Set();
break;
case Status.Started:
OnTime(m_rtPosition, pvContext);
break;
case Status.EndOfStreaming:
m_hEOS.Set();
break;
case Status.Error:
m_hEOS.Set();
break;
}
return NOERROR;
}
正如你所见,我们调用了 `SetUserProvidedClock` 方法,这是因为我们可能不在实时工作,并且计时基于 Graph 时钟。
加载轨道
文件加载后,我们会检查其配置文件并提取每个流的配置。根据该配置,我们创建轨道并将其添加到输出列表中。
if (m_pWMReader == IntPtr.Zero) return E_UNEXPECTED;
HRESULT hr;
IWMProfile pProfile = (IWMProfile)Marshal.GetObjectForIUnknown(m_pWMReader);
int nStreams;
hr = (HRESULT)pProfile.GetStreamCount(out nStreams);
if (SUCCEEDED(hr))
{
if (nStreams > 0)
{
for (int i = 0; i < nStreams; i++)
{
IWMStreamConfig pStreamConfig;
hr = (HRESULT)pProfile.GetStream(i, out pStreamConfig);
if (SUCCEEDED(hr))
{
WMTrack pTrack = new WMTrack(this,i, ref pStreamConfig);
pStreamConfig = null;
if (S_OK == pTrack.InitTrack())
{
m_Tracks.Add(pTrack);
if (m_rtDuration < pTrack.TrackDuration)
{
m_rtDuration = pTrack.TrackDuration;
}
}
else
{
pTrack.Dispose();
pTrack = null;
}
}
}
}
else
{
hr = E_INVALIDARG;
}
}
pProfile = null;
每个轨道都有其自己的附加初始化,我们在这里初始化轨道设置并验证是否可以使用它进行流式传输。同时,我们应该初始化 `m_rtDuration` 变量,以便我们可以支持跳转。文件持续时间可以通过 `IWMHeaderInfo` 接口作为指定的属性检索。
IWMHeaderInfo pHeader = (IWMHeaderInfo)Marshal.GetObjectForIUnknown(m_pWMReader);
AttrDataType _type;
long _duration = 0;
short wBytes = (short)Marshal.SizeOf(_duration);
IntPtr _ptr = Marshal.AllocCoTaskMem(wBytes);
short wStreamNum = 0;
if (S_OK == pHeader.GetAttributeByName(ref wStreamNum, "Duration", out _type, _ptr, ref wBytes))
{
_duration = Marshal.ReadInt64(_ptr);
if (wBytes == Marshal.SizeOf(_duration))
{
m_rtDuration = _duration;
}
}
Marshal.FreeCoTaskMem(_ptr);
pHeader = null;
解复用
我们使用一个通用的线程进行解复用,但实际处理是在 WMF SDK 的单独线程中进行的,我们只是通过回调从这些线程中获取结果。在重写的 `OnDemuxStart` 中,我们调用 `IWMReader.Start` 来开始处理数据。
public override HRESULT OnDemuxStart()
{
IUnknownImpl _unknown = new IUnknownImpl(m_pWMReader);
WMStartProc _proc = _unknown.GetProcDelegate<WMStartProc>(10);
return (HRESULT)_proc(m_pWMReader,m_rtPosition, 0, 1, IntPtr.Zero);
}
由于线程问题,我在这里对单个函数进行了包装。我认为这比完全包装接口要容易。我只使用 `IWMReader` 接口的 3 个方法,因此不需要额外的包装。在实际的解复用器线程中,我们只是等待 Stop 或 EOS 事件,因为 WMF SDK 使用自己的线程来提供输出包。
public override HRESULT ProcessDemuxPackets()
{
if (0 == WaitHandle.WaitAny(new WaitHandle[] { m_hEOS, m_hQuit }))
{
return S_FALSE;
}
return NOERROR;
}
`m_hQuit` 事件是全局停止通知。`m_hEOS` 事件在我们从 WM 回调收到 End of stream 通知时设置 - 请参阅上面关于 `OnStatus` 实现的主题。我们重写了 `OnDemuxStop` 并在其中调用 `IWMReader.Stop` 方法。
public override HRESULT OnDemuxStop()
{
IUnknownImpl _unknown = new IUnknownImpl(m_pWMReader);
WMStopProc _proc = _unknown.GetProcDelegate<WMStopProc>(11);
return (HRESULT)_proc(m_pWMReader);
}
在处理过程中,由于我们使用的是自己的时钟,我们收到了 `IWMReaderCallbackAdvanced.OnTime` 通知,这意味着先前的时间段已过。我们只需要将时间增加到下一个时间段。
public int OnTime(long cnsCurrentTime, IntPtr pvContext)
{
IUnknownImpl _unknown = new IUnknownImpl(m_pWMReader);
IntPtr pReaderAdvanced;
Guid _guid = typeof(IWMReaderAdvanced).GUID;
_unknown._QueryInterface(ref _guid, out pReaderAdvanced);
_unknown = new IUnknownImpl(pReaderAdvanced);
WMDeliverTimeProc _proc = _unknown.GetProcDelegate<WMDeliverTimeProc>(5);
cnsCurrentTime += UNITS;
int hr = _proc(pReaderAdvanced,cnsCurrentTime);
_unknown._Release();
return hr;
}
我们收到流样本,这意味着它们是原始的 - 就像它们存储在文件中一样(通常是压缩的)。我们也可以配置为接收未压缩的样本 - 你可以自己添加此功能并扩展过滤器 :)。因此,一旦新样本准备好,它就会调用 `IWMReaderCallbackAdvanced.OnStreamSample` 回调方法。在这里,我们将样本数据添加到轨道的队列中。
public int OnStreamSample(short wStreamNum, long cnsSampleTime, long cnsSampleDuration, int dwFlags, IntPtr pSample, IntPtr pvContext)
{
foreach (WMTrack _track in m_Tracks)
{
if (_track.StreamNum == wStreamNum)
{
PacketData _data = new WMPacketData(pSample);
_data.Start = cnsSampleTime;
_data.Stop = cnsSampleTime + cnsSampleDuration;
_data.SyncPoint = (dwFlags & 1) != 0;
_track.AddToCache(ref _data);
break;
}
}
return NOERROR;
}
这里的 `pSample` 参数是 `INSSBuffer` 接口。我们应该在样本被处理并释放之前持有它。这是必要的,因为分配器会尝试填充任何可用的缓冲区,如果我们不持有它,则处理不会停止。也许在其他文章中我会描述如何处理分配器。因此,我们使用 `WMPacketData` 类来保存该样本,将 `PacketData` 基类字段初始化为 `INSSBuffer`。
public WMPacketData(IntPtr pBuffer)
{
NSBuffer = pBuffer;
if (pBuffer != IntPtr.Zero)
{
Marshal.AddRef(pBuffer);
IUnknownImpl _puffer = new IUnknownImpl(pBuffer);
GetBufferAndLengthProc _proc = _puffer.GetProcDelegate<GetBufferAndLengthProc>(7);
IntPtr _ptr;
_proc(pBuffer, out _ptr, out Size);
Buffer = new byte[Size];
Marshal.Copy(_ptr, Buffer, 0, Size);
}
}
我们调用 `INSSBuffer.GetBufferAndLength` 方法来获取数据指针和长度。
跳转
这里的跳转实现非常简单。由于我们有 `SeekToTime` 方法可以从基类覆盖;我们使用它并仅将时序位置存储在类变量中。
public override HRESULT SeekToTime(long _time)
{
m_rtPosition = _time;
return base.SeekToTime(_time);
}
**注意**:我们此时知道所有线程都已停止。因此,在我们再次开始处理之后,我们只需在 `IWMReader.Start` 方法中指定存储的位置,该方法在重写的 `OnDemuxStart` 中调用。
实现轨道
在轨道中,我们只需要执行附加的初始化并提供媒体类型以进行连接,因为处理是在过滤器中完成的。
轨道初始化
在每个轨道中,我们需要设置媒体数据类型(音频、视频)。检索流编号 - 使用它,我们就可以在回调中决定为哪个轨道获取样本。
hr = (HRESULT)m_pStreamConfig.GetStreamNumber(out m_wStreamNum);
if (FAILED(hr)) return hr;
{
Guid _type;
hr = (HRESULT)m_pStreamConfig.GetStreamType(out _type);
if (FAILED(hr)) return hr;
if (_type == MediaType.Video)
{
m_Type = TrackType.Video;
}
if (_type == MediaType.Audio)
{
m_Type = TrackType.Audio;
}
}
此外,我们配置该轨道的样本传递设置。
WMParser pParser = (WMParser)m_pParser;
IWMReaderAdvanced2 pReaderAdvanced = (IWMReaderAdvanced2)Marshal.GetObjectForIUnknown(pParser.WMReader);
StreamSelection _selected;
hr = (HRESULT)pReaderAdvanced.GetStreamSelected(m_wStreamNum, out _selected);
if (SUCCEEDED(hr))
{
if (_selected == StreamSelection.Off)
{
m_Type = TrackType.Unknown;
}
else
{
hr = (HRESULT)pReaderAdvanced.SetReceiveStreamSamples(m_wStreamNum, true);
ASSERT(hr == S_OK);
hr = (HRESULT)pReaderAdvanced.SetAllocateForStream(m_wStreamNum, false);
ASSERT(hr == S_OK);
int dwSize = 0;
if (SUCCEEDED(pReaderAdvanced.GetMaxStreamSampleSize(m_wStreamNum, out dwSize)))
{
m_lBufferSize = dwSize;
}
}
}
pReaderAdvanced = null;
在上面的代码中,如果文件中的流未启用,我们将媒体类型设置为 `Unknown`;这样,该流将不会在过滤器中显示为输出引脚。在其他情况下,我们指定为该流接收原始样本。此外,我们获取流的最大缓冲区大小并设置为使用默认分配器。
实现 GetMediaType
为每个轨道创建一个输出引脚,为了进行连接,我们必须提供输出媒体类型。这在轨道的 `GetMediaType` 方法中完成。为了获取类型信息,我们使用 `IWMMediaProps` 接口及其 `GetMediaType` 方法。我们应该调用此方法两次:第一次获取所需大小,第二次填充缓冲区。由于我们在其中使用 WMF SDK,`WM_MEDIA_TYPE` 结构与 `AM_MEDIA_TYPE` 类似,因此我们可以使用我们的托管 `AMMediaType` 类来转换分配的数据。
public override HRESULT GetMediaType(int iPosition, ref AMMediaType pmt)
{
if (iPosition < 0) return E_INVALIDARG;
if (iPosition > 0) return VFW_S_NO_MORE_ITEMS;
IWMMediaProps pMediaProps = (IWMMediaProps)m_pStreamConfig;
int cType = 0;
pMediaProps.GetMediaType(IntPtr.Zero, ref cType);
IntPtr pMediaType = Marshal.AllocCoTaskMem(cType);
pMediaProps.GetMediaType(pMediaType, ref cType);
pmt.Set((AMMediaType)Marshal.PtrToStructure(pMediaType,typeof(AMMediaType)));
Marshal.FreeCoTaskMem(pMediaType);
return S_OK;
}
过滤器注意事项
生成的过滤器可以解复用 Windows 媒体文件,如 asf、wmv 和 wma。我没有发现任何问题。包中包含过滤器的 2 个版本 - Splitter 和 Source。你还可以尝试用这个过滤器做什么:
- 实现以在压缩样本的同时传递未压缩样本。
- 实现作为网络源的过滤器,它将接收和播放 Windows 媒体网络流。