公安部制定的GBT 28181标准广泛应用于安防领域,这个标准规定了传输的视音频数据要封装成PS流格式。PS格式(原名叫MPEG-PS)在很多领域已经应用了很长一段时间,特别是在安防、广播电视、影音制作等领域,我们熟知的DVD格式(vob)就是用PS封装的。这篇文章我打算给大家讲解怎么实现一个PS流的实时流播放器,通过这篇文章学习,大家就知道一个实时流播放器应该如何设计、如何对PS流做处理等。
一、基本概念
1)ES
ES–Elementary Streams 是直接从编码器出来的数据流,可以是编码过的视频数据流(H.264,MJPEG等),音频数据流,或其他编码数据流的统称。ES流经过PES打包器之后,被转换成PES包。
ES是只包含一种内容的数据流,如只含视频或只含音频等,打包之后的PES也是只含一种性质的ES,如只含视频ES的PES,只含音频ES的PES等。
2)PES
PES–Packetized Elementary Streams (分组的ES),ES形成的分组称为PES分组,是用来传递ES的一种数据结构。PES流是ES流经过PES打包器处理后形成的数据流,在这个过程中完成了将ES流分组、打包、加入包头信息等操作(对ES流的第一次打包)。PES流的基本单位是PES包。PES包由包头和payload组成。
3)PS
PS–Program Stream(节目流)PS流由PS包组成,而一个PS包又由若干个PES包组成(到这里,ES经过了两层的封装)。PS包的包头中包含了同步信息与时钟恢复信息。
4)PTS、DTS
PTS–PresentationTime Stamp(显示时间标记)表示显示单元出现在系统目标解码器(H.264、MPEG4等)的时间。
DTS–Decoding Time Stamp(解码时间标记)表示将存取单元全部字节从解码缓存器移走的时间。
PTS/DTS是打在PES包的包头里面的,这两个参数是解决音视频同步显示,防止解码器输入缓存上溢或下溢的关键。每一个I(关键帧)、P(预测帧)、B(双向预测 帧)帧的包头都有一个PTS和DTS,但PTS与DTS对于B帧不一样。
PTS/DTS是相对SCR(系统参考)的时间戳,系统时钟频率为90Khz,是以90000为单位的,PTS/DTS到ms的转换公式是PTS/90,转换到秒为PTS/90000。如果没有B帧,PTS和DTS的顺序应该是一致的,如果有B帧,则需要先解码P帧,才能解出来B帧,所以需要PTS和DTS来控制解码时间和显示时间。
根据对前面概念的理解,我总结出以下几点:
1. PS流是一种复合流,可以包含视频流和音频流数据,也可以只包含一种流(视频、音频)的数据;
2. PES流是对原始ES流进行的第一层封装,PES流的基本单位是PES包,由包头和payload组成。
3. ES流即音视频裸流,是从编码器里面出来的原始视频音频流,ES流只包含一种内容,里面是视频或者音频;
4. ES首先需打包成PES包,然后PES加上PS包头,变成了标准的PS流进行存储或传输;
5. PES帧是变长的,每个帧的长度可能不一样;
6. 一般情况下是一帧数据放在一个PES包里面,但是一个PES包的最大长度为65535字节,因此一帧数据有可能被分为多个PES;
7 一个PS包包含若干个PES帧,是由PS头和一个或多个PES帧所组成。
8. PS流解码时根据PS包里面的DTS和PTS时间戳确定帧的解码顺序和播放的时间。
9. 解封装PS流是一个封装的逆过程,需要先从原始的PS包里面去掉PS头,分解出PES包,然后去掉PES包头,得到ES裸流。
二、PS流码流结构
I Frame PS_Header | PS_Map | PES |.......|PES
P Frame PS_Header | PES | .......|PES
Audio Frame PS_Header | (PS_Map) | PES (音频一般封装在一个PES里面即可)
更详细的结构图如下:
这里要特别注意上面的PS System Map,简称PSM。关于PSM的介绍:
(以下这段内容摘录自:博客「SunkingYang」的文章《H264解码之PS流解析》,原文链接:https://blog.csdn.net/y601500359/article/details/97649112)
————————————————
PSM介绍
PSM提供了对PS流中的原始流和他们之间的相互关系的描述信息;PSM是作为一个PES分组出现,当stream_id == 0xBC时,说明此PES包是一个PSM;PSM是紧跟在系统头部后面的;PSM是作为PS包的payload存在的;
PSM由很多字段组成,其字节顺序如下所示:
其中,最关键的是这两个字段:
stream_type字段:类型字段,占位8bit;表示原始流ES的类型;这个类型只能标志包含在PES包中的ES流类型;值0x05是被禁止的;常见取值类型有:MPEG-4 视频流:0x10;H.264 视频流:0x1B;G.711 音频流:0x90;因为PSM只有在关键帧打包的时候,才会存在,所以如果要判断PS打包的流编码类型,就根据这个字段来判断;
elementary_stream_id字段:流ID字段,占位8bit;表示此ES流所在PES分组包头中的stream_id字段的值;其中0x(C0~DF)指音频,0x(E0~EF)为视频;
PSM只有在关键帧打包的时候,才会存在;IDR包含了SPS,PPS和I帧;每个IDR NALU前一般都会包含SPS、PPS等NALU,因此将SPS、PPS、IDR的NALU 封装为一个PS 包,包括PS头,PS system header,PSM,PES;所以一个IDR NALU PS 包由外到内顺序是:PS header| PS system header | PSM| PES。对于其它非关键帧的PS包,就简单多了,直接加上PS头和PES 头就可以了。顺序为:PS header | PES header | h264raw data。以上是对只有视频video 的情况,如果要把音频Audio也打包进PS 封装,只需将数据加上PES header 放到视频PES 后就可以了。顺序如下:PS 包=PS头|PES(video)|PES(audio);
————————————————
简而言之,播放器需要拿到PSM表的信息,从里面提取出各个Stream的elementary_stream_id,stream_type,这样就知道了哪个流对应哪种编码格式。
三、播放器的功能
1. 播放本地PS文件(这个功能不是这个例子的重点,但是为了便于测试,也加进来了,其实文件播放和实时流播放有些流程是一样的)。
2. 支持从网络接收MPEG-PS流,用UDP方式接收数据,支持接收UDP裸流或带RTP头的MPEG-PS流。
3. 对网络收到的PS流进行保存。
4. 支持从内存中读取MPEG-PS流,支持对流进行解封装(PS-》PES,PES-》ES),最终将流转化成ES流格式。
5. 将ES流送给FFmpeg解码,显示视频。
6. 能够获得PS流中视音频轨的相关信息(视频编码格式、视频宽高、音频编码格式)。
三、播放器设计
播放器的界面如下图:
整个播放器的处理流程图如下:
按处理流程可分为几个步骤:UDP接收数据、PS拆包(PS->PES, PES->ES,最终分离出Video ES和Audio ES)、解码视频、解码音频。为了提高并发效率,我采用了多线程机制,其中接收和PS拆包位于一个线程,而视频解码用另外一条线程处理,之间有个队列将接收线程分离出来的ES帧Push到队列里作临时存储;而解码线程则从队列里拿数据(Pop Frame),拿到一帧就解一帧。上述的过程如下图所示:
注意:我的例子中并没有实现音频队列,也没有对音频包做处理。
接收模块支持UDP(单播、组播),支持数据带RTP头和无RTP头这两种情况;PS流的解封装和分离放到一个动态库里实现,供外部调用;解码是用FFmpeg;显示图像用到了GDI。
四、开发详解
因为PS流解封装和分离是实现在一个动态库(DLL)里,我们先熟悉一下这个DLL的接口:
#include "SDKDef.h"
//初始化SDK
PLAYPT_API BOOL PT_InitSDK();
//注销SDK
PLAYPT_API BOOL PT_UnitSDK();
//获取SDK版本号
PLAYPT_API LONG PT_GetSDKVersion();
//设置缓冲的条件,根据帧数或字节数
//下面两个条件只能一个生效,如果nBufferFrames非0则根据帧数缓冲;如果nBufferFrames为0,BufferBytes不为0,则根据字节数缓冲。
//参数:
//nBufferFrames -- 缓冲要达到的帧数
//BufferBytes -- 缓冲要达到的字节数
PLAYPT_API void PT_SetBufferStreamParams(UINT nBufferFrames, UINT BufferBytes); //设置缓冲参数
//打开PS/TS格式的流,支持从文件或内存读取流
//参数:
//srcType -- 流来自于文件或内存,_FILE_SOURCE--来自于文件,_MEM_SOURCE---来自于内存
// lpszFilePath -- 文件路径,如果是内存流,传NULL
// nFileType --流类型(1--PS, 2--TS)
// bParseESStream -- 是否解析视音频流,提取信息。如果视频流格式不是MPEG2/MPEG4/H264这几种之一,则将该参数设为FALSE
// handle -- 返回的这个句柄来调用其他函数;
// dwError -- 失败时返回的错误码;
//
PLAYPT_API BOOL PT_OpenFile(SOURCE_TYPE srcType, LPCTSTR lpszFilePath, int nFileType, BOOL bParseESStream, int & handle, DWORD & dwError);
PLAYPT_API BOOL PT_CloseFile(int handle); //关闭文件或内存流
PLAYPT_API BOOL PT_Pause(int handle); //暂停播放
PLAYPT_API BOOL PT_Play(int handle); //开始播放
PLAYPT_API BOOL PT_IsRunning(int handle); //是否正在播放
PLAYPT_API BOOL PT_AddStreamData(int handle, BYTE * pData, int nBytes ); //插入PS/TS流
PLAYPT_API BOOL PT_SetFrameCallback(int handle, EsFrameCallback lpFrameCB); //设置数据回调(回调分离出来的ES流数据)
//获取视频流的信息。说明:当PT_OpenFile函数传入参数bParseESStream为True时才能调用该函数返回视频流的信息
PLAYPT_API BOOL PT_GetVideoInfo(int handle, VideoEncodeFormat & videoformat, int& nWidth, int& nHeight, int & nTrackNum);
//获取音频流的信息。说明:当PT_OpenFile函数传入参数bParseESStream为True时才能调用该函数返回音频流的信息
PLAYPT_API BOOL PT_GetAudioInfo(int handle, AudioEncodeFormat & nType, int & nTrackNum);
PLAYPT_API BOOL PT_GetFileDuration(int handle, __int64 & llDuration); //获取文件播放时长(只对文件有效)
PLAYPT_API BOOL PT_Seek(int handle, __int64 llPos); //跳到某个播放时间点(只对文件有效)
PLAYPT_API BOOL PT_GetPlayPos(int handle, __int64 & llPos); //获取当前播放时间点
//以下针对TS流
PLAYPT_API BOOL PT_IsPMTFound(int handle); //流中是否找到PMT表(Program Map Table)信息
PLAYPT_API BOOL PT_GetTSProgramSize(int handle, int & nProgramSize); //获取TS流中的节目个数
PLAYPT_API BOOL PT_GetTSProgramInfo(int handle, int nProgramNum, int& nStreamNum, ESStreamInfo streams[4]); //获取节目流信息
//以下针对PS流
PLAYPT_API BOOL PT_GetPSStreamNum(int handle, int & nStreamNum); //获取流的数目(视频流+音频流)
PLAYPT_API BOOL PT_GetPSStreamInfo(int handle, int& nStreamNum, ESStreamInfo streams[4]); //获取每个流的信息(PES ID和StreamType)
将PS包的解封包、分离等处理放到一个DLL中实现是为了隐藏其内部复杂性,方便外部调用者使用,并且封装成一个模块也便于以后重用。
由于接口里用到了某些自定义类型,所以还需要包含头文件:SDKDef.h,SDKDef.h文件的内容如下:
typedef int (WINAPI * EsFrameCallback)(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType, __int64 llPts, int nFrameType);
enum SOURCE_TYPE
{
_FILE_SOURCE, //文件流
_MEM_SOURCE, //内存流
};
//视频编码类型
enum VideoEncodeFormat
{
_VIDEO_NONE = 0,
_VIDEO_MPEG1 = 1,
_VIDEO_MPEG2,
_VIDEO_MPEG4,
_VIDEO_H264,
//_VIDEO_H265,
};
//音频编码类型
enum AudioEncodeFormat
{
AUDIO_DEFAULT = 20,
AUDIO_PCMA,
AUDIO_PCMU,
AUDIO_MP3,
AUDIO_MP2,
AUDIO_AAC,
AUDIO_UNSUPPORT
};
typedef struct _ESStreamInfo_
{
BYTE streamType;
WORD pid;
}ESStreamInfo;
其实,我们看了SDK的接口大概能知道每个函数的作用,并且结合注释说明,基本上已经清楚函数怎么使用。但是大概的调用流程还有一些函数的使用注意细节我还是需要跟大家讲一下。
首先,我们需要调用PT_InitSDK初始化SDK,接着调用PT_SetBufferStreamParams函数设置缓冲区参数(这一步骤可选),然后,调用PT_OpenFile打开一个文件或流。PT_OpenFile函数既支持从文件读取也支持从内存读取。如果是从内存读取,则需要调用另外一个接口:PT_AddStreamData,这个函数不停地向缓冲区中插入数据,保证SDK里面的读线程有数据可读。PT_OpenFile会返回一个句柄,表示文件或这个流的唯一实例ID,通过该句柄来调用该实例的其他接口函数。PT_OpenFile传入的参数中有个参数:bParseESStream,这是一个很重要的参数,该参数会影响到内部对视音频流的解析。如果要获得音视频流的信息(音视频编码格式、视频分辨率等),则将该参数设置成True,但是打开该参数会增加函数的处理工作量,增加PT_OpenFile函数调用的时间,关于更多这个参数的说明和使用注意事项,后面还会提到。再下一步,要设置回调函数接口接收分离出来的ES数据,设置回调的接口是:PT_SetFrameCallback(int handle, EsFrameCallback lpFrameCB); 其中回调函数EsFrameCallback 的原型是:
typedef int (WINAPI * EsFrameCallback)(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType, __int64 llPts, int nFrameType);
这个回调函数会传递分离出来的ES帧(视频、音频),各个参数意义:
// int handle -- SDK的播放句柄;
// BYTE * pBuf -- ES帧的数据起始地址;
// int nBufSize -- 数据的大小;
// int nTrackNum--流ID号,区别不同流的唯一标识符;
// int nStreamType -- 流类型, 0x1b -- H264; 0x02 --MPEG2, 0x10 --MPEG4
// __int64 llPts -- 时间戳,以10000000L为单位;
// int nFrameType -- 帧的类型。nFrameType = 0, 未能获取类型; nFrameType = 1, I帧;nFrameType = 2, P帧; nFrameType = 3, B帧
上面的参数:流ID--nTrackNum有什么用呢?前文说到:PS流的每个流有elementary_stream_id和stream_type,这个elementary_stream_id就是我们这里的nTrackNum,在PS流的格式定义中,视频流的ID和音频流的ID有个范围(看前文PSM介绍一节)。一般的情况,视频流TrackNum是0xE0,音频流是0xC0。通过TrackNum我们就能知道回调的ES帧是视频流还是音频流,更具体些通过SreamType可以判断出流的类型和流的编码格式,每个类型值对应一种编码格式,比如视频:0x1b -- H264; 0x02 --MPEG2, 0x10 --MPEG4。但是,这个SDK目前还没有实现获取流的StreamType,回调返回的StreamType为0的,这样怎么获得视音频流的格式呢?那就要通过另外的接口:PT_GetVideoInfo/PT_GetAudioInfo。
打开一个文件或流后,我们可以调用PT_GetVideoInfo、PT_GetAudioInfo分别获得视频流和音频流的格式信息,让我们看看这两个函数的原型:
//获取视频流的信息。
PLAYPT_API BOOL PT_GetVideoInfo(int handle, VideoEncodeFormat & videoformat, int& nWidth, int& nHeight, int & nTrackNum);
//获取音频流的信息。
PLAYPT_API BOOL PT_GetAudioInfo(int handle, AudioEncodeFormat & nType, int & nTrackNum);
对于视频流,可返回视频的编码格式、分辨率、视频轨道ID信息;对于音频,可返回音频的编码格式,音频轨道ID信息。
但是目前这个SDK只能解析少数几种视音频格式,支持的格式可以看SDKDef.h文件里枚举类型VideoEncodeFormat和AudioEncodeFormat的定义,其中视频只支持MPEG1/MPEG2/MPEG4/H264。如果是别的格式怎么办呢?比如PS流里封装的视频流是H265或SVC编码,那SDK能解析吗?因为PS容器里能包含的视音频格式有很多种,我不可能对每一种都支持,那工作量是非常大的,但是因为SDK主要做的工作是PS解包以及分离出视音频的ES帧,本来跟流的编码格式无关,所以我设计SDK的时候是允许容器中的流是任何类型的编码格式。为了能支持这一点,我在SDK内部不会对每一种格式的流都会进行解析,只对MPEG1/MPEG2/MPEG4/H264格式进行解析和提取信息。在SDK接口上,提供一个参数:bParseESStream,这个参数就是前面的打开流接口:PT_OpenFile的第4个参数,这个参数让用户设置是否让SDK解析流的格式,如果是MPEG1/MPEG2/MPEG4/H264格式,建议将该变量设为True,如果是别的格式,就设成False。对于非SDK内部支持的格式,用户需要知道他们接收的PS数据中各个ES流是哪一种编码格式,并在应用程序中实现对这种格式的信息提取和解码处理。
自此,我们已经说了SDK的几个接口的使用方法,从调用PT_OpenFile函数,到设置回调,再到调用获取视音频格式的接口:PT_GetVideoInfo/PT_GetAudioInfo。这里还要补充几点。
1. 调用PT_OpenFile函数前必须先调用PT_AddStreamData向SDK插入数据,因为SDK内部实现了一个缓冲区(默认是2M字节大小),在打开流之前需要从缓冲区预读一段数据,根据读到的数据初始化内部一些变量,并获取PS流的格式信息,以及每个流的编码格式信息(如果bParseESStream参数为True)。如果填充的数据不够,则PT_OpenFile函数会返回False,表示打开流失败。我们可设置缓冲区要缓存多少数据才结束,可通过调用PT_SetBufferStreamParams接口来设置,其中第一个参数是缓冲的帧数,第二个参数是要缓冲的字节数。注意这两个变量只能同时有一个生效,优先是按帧数,其次是按字节数(建议按帧数缓冲,因为不会受码率大小影响)。比如我设置了缓冲5帧,则缓冲区至少要收到5个视频的PES帧才初始化成功,并返回。默认情况,PT_OpenFile函数会等到缓冲4个视频PES帧才返回。
2. 如果PT_OpenFile函数返回失败,则可能没有缓冲够足够的数据,或超时,或解析视音频格式的信息出错了(当bParseESStream = True)。这个函数有个等待时间,如果超过5秒还没有达到缓冲的条件,则退出并返回失败;如果流有损坏或格式不正确,也会导致PT_OpenFile函数返回失败。
3. 调用PT_OpenFile函数只是预读数据,并没有开始运行和输出ES帧,所以ES回调函数还没有执行。
要从PS流里分离出ES帧,我们必须调用开始运行任务的接口:PT_Play接口,这个接口调用之后,ES回调函数(就是前面设置的EsFrameCallback回调)就会被触发,开发者在应用层可获取到分离出来的ES数据。
之后,我们不停地调用PT_AddStreamData向SDK写数据,这样SDK就会读数据,然后拆包、解封装,调用回调函数向应用层传递ES帧数据。这里有个问题:就是写数据和读数据如何同步的?因为数据是从UDP接收线程那里先获得,如果发送端发送流的速度很快,那么就会以很快的速度向SDK写数据,如果读线程处理有延时或读得慢,那么就会造成缓冲区很快满。我设计的缓冲区是一个环形的可循环读写的内存块,目前缓冲区大小是2M,读写指针应保持一个安全距离,如果写指针和读指针距离很接近或前者超越后者,则表示缓冲区满了,并存在数据丢失的情况。这是做实时流播放器经常遇到的一个问题。因为我们不知道发送端以多快速度将数据发送过来,接收线程是一收到数据就向SDK写数据的,所以SDK最好的处理策略是:也以最快的速度去读数据,即一收到数据就马上解析,尽量保证处理低延时。SDK要实现内部操作尽量不阻塞不难,但是因为分离出ES,SDK还要调回调函数传数据给应用层,上层应用在回调函数里可能做了一些延时大的操作,比如解码、显示图像等。所以,要保证不阻塞SDK的内部读线程,我们还需要应用层的配合,要求应用层在回调函数里尽快返回,将延时大的操作放在别的地方。这时候,大家自然会想到多线程处理,还有缓冲队列的解决方案。没错,本人也是按照这个方案来解决的。这就是我前面的播放器设计一节里的流程图所表达的思路:数据接收、PS拆包处理、输出ES帧放在一个线程,而解码ES帧和显示放在另外一个线程。其实,确切的说,接收线程跟PS包的读数据是分开两个不同线程的,前者是在应用程序在创建UDP Socket时创建,后者是调用SDK接口PT_OpenFile由SDK创建,两者通过缓冲区交换数据。而现在我们需要第2个缓冲区,就是SDK解包之后分离出来ES帧放到一个缓冲队列里(就是流程图中的视频包队列),在SDK输出ES帧给应用层的时候(在回调函数)把数据扔到这个队列,一旦Push完数据就继续其他处理,这样SDK的读线程在整个处理流程中就不会有阻塞,保证了较快的处理速度。因为缓冲队列是动态增长的,不像固定长度的缓冲区,没有长度限制,如果插入数据突然很快(网络抖动),顶多表现为缓冲队列越来越长,内存占用升高,而后面如果插入数据速度恢复正常,缓冲队列累积的帧也会很快被消耗掉,最终恢复平衡。
好了,PS流SDK部分就讲解完了。我下面说说应用层的一些处理工作。
在应用层的主窗口中,我们定义以下几个对象:
int m_PlaySDKHandle; //SDK句柄
HANDLE m_hDecodeThread; //解码线程句柄
CDecodeVideo m_VideoWindow; //解码视频
CImagePainter m_wndPainter; //显示图像
CStreamSocket m_StreamSocket; //UDP接收数据
其中, m_PlaySDKHandle就是前面说的SDK实例句柄。接收UDP数据由CStreamSocket类型的对象m_StreamSocket负责处理,而解码视频、显示图像分别由m_VideoWindow、m_wndPainter负责处理。
下面按照应用程序的执行流程,讲解一下几个重要的步骤:
1. 创建UDP接收线程、打开一个流
//从内存中读取流
int nFileFormat = _PS; //在这里修改格式
UINT nRecvPort = 1234; //本地接收端口
//创建UDP Socket从网络接收数据
m_StreamSocket.SetController(this);
m_StreamSocket.SetUDPLocalPort(nRecvPort); //该端口必须未必占用
m_StreamSocket.SetRecvRTP(FALSE); //是否包含RTP包头
m_StreamSocket.StartReceiving(); //开始接收
//PT_SetBufferStreamParams(10, 0); //设置缓冲区参数
//打开流(从内存读数据)
if(!PT_OpenFile(_MEM_SOURCE, NULL, nFileFormat, TRUE, m_PlaySDKHandle, dwError))
{
ASSERT(0);
OutputDebugString("PT_OpenFile failed \n");
return 1;
}
2. 获取每个流的格式以及初始化解码器
int nPrograms = 0;
int nStreamNum = 0;
ESStreamInfo streamInfo[4];
int v_stream_type = 0;
int v_track_id = 0;
VideoEncodeFormat vEncodeFmt = _VIDEO_NONE;
BOOL bRet;
int cx, cy;
//获得视频的编码格式、图像宽、高、轨道ID信息
//PT_GetVideoInfo正常情况下会返回TRUE,但如果上一步调用PT_OpenFile函数传入的第4个参数(bParseESStream)为False,则PT_GetVideoInfo函数(和PT_GetAudioInfo函数)始终返回FALSE。这时候,要用另外一种方法获取视频/音频流的信息
if(!PT_GetVideoInfo(m_PlaySDKHandle, vEncodeFmt, cx, cy, v_track_id))
{
if(nFileFormat == _PS)
{
//获取流的数目和每个流的StreamType
//!!!注意:SDK暂时还不能获得PS流的StreamType,不能通过此判断编码格式,但是能获得流的PES ID
//bRet = PT_GetPSStreamNum(m_PlaySDKHandle, nStreamNum); //获取TS流中的节目个数
bRet = PT_GetPSStreamInfo(m_PlaySDKHandle, nStreamNum, streamInfo); //获取指定节目的个数
TRACE("nStreamNum: %d \n", nStreamNum);
for(int i=0; i<nStreamNum; i++)
{
TRACE("pid = %d, streamtype = %x \n", streamInfo[i].pid, streamInfo[i].streamType);
//switch(streamInfo[i].streamType)
//{
//case 0x10: //MPEG-4
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_MPEG4;
// break;
//case 0x02: //MPEG-2
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_MPEG2;
// break;
//case 0x1b: //H264
// v_stream_type = streamInfo[i].streamType;
// vEncodeFmt = _VIDEO_H264;
// break;
//default: //其他
// break;
//}
if(streamInfo[i].pid >= 0xe0 && streamInfo[i].pid < 0xf0) //视频的PES ID一般落在这个范围内
{
v_track_id = streamInfo[i].pid;
vEncodeFmt = _VIDEO_H264; //固定格式,暂时没有别的办法!看前面注意:
}
}//for
}
}
m_nVStreamID = v_track_id;
if(vEncodeFmt != _VIDEO_NONE)
{
m_VideoFmt = vEncodeFmt;
m_VideoWindow.StartDecode(vEncodeFmt);
}
else
{
OutputDebugString("未知解码格式 \n");
ASSERT(0);
}
3. 设置数据回调、开始运行SDK任务、创建解码线程。
PT_SetFrameCallback(m_PlaySDKHandle, SDKEsFrameCallback);
PT_Play(m_PlaySDKHandle);
#if 1
m_decoding_thread_run = true;
DWORD threadID = 0;
m_hDecodeThread = CreateThread(NULL, 0, decoding_thread, this, 0, &threadID);
#endif
下面我们看看CStreamSocket类是如何从网络接收数据的(Socket创建和初始化的过程就省略掉了)。
CHAR * buff = new CHAR[SAMPLE_SIZE];
ASSERT(buff != NULL);
memset(buff, 0, SAMPLE_SIZE);
int buffsize = 0;
const int minsize = 4*1024; //如果收到的数据长度小于这个值,则继续接收,直到收到的数据长度累加到超过该值时才向上层传递数据(回调),这样可以保证上层对包的处理不会过于频繁
DWORD tTimeLast = GetTickCount();
DWORD dwTotal = 0;
int len = 0;
char rtpPacket[16*1024] = {0};
int fromlen = sizeof(addr);
HANDLE m_fp = NULL; //写文件句柄,保存收到的流到文件(Debug模式下用)
TCHAR szDumpFile[256] = {0};
int nFIndex = 0;
DWORD dwStartTick = GetTickCount();
#ifdef _DEBUG
SYSTEMTIME systime;
::GetLocalTime(&systime);
_stprintf(szDumpFile, _T("D:\\%04d-%02d-%02d-%02d%02d%02d(%d).ts"),
systime.wYear, systime.wMonth, systime.wDay, systime.wHour, systime.wMinute, systime.wSecond, nFIndex++);
#endif
BOOL bHasData = FALSE;
RTP_FIXED_HEADER * rtp_hdr = NULL;
fd_set sel_old,sel_use;
struct timeval tv_rcv = {0};
FD_ZERO( &sel_old);
FD_SET( m_Socket, &sel_old);
while(!m_bExit)
{
sel_use = sel_old;
tv_rcv.tv_sec = 0;
tv_rcv.tv_usec = 10000;
int sel_count = select( 0, &sel_use, NULL, NULL, &tv_rcv);
if( sel_count <= 0 )
{
Sleep(1);
continue;
}
if( !FD_ISSET(m_Socket, &sel_use))
{
continue;
}
if(m_bRTP)
{
len = recvfrom(m_Socket, rtpPacket, 8192, 0,
(SOCKADDR*)&addr, &fromlen);
}
else
{
len = recvfrom(m_Socket, &buff[buffsize], 8192, 0,
(SOCKADDR*)&addr, &fromlen);
}
if(len <= 0)
{
Sleep(2);
TRACE(TEXT("recvfrom error: %d \n"), ::WSAGetLastError());
if(bHasData)
{
TRACE(_T("接收超时或断开连接!\n"));
}
break;
}
if(m_bRTP)
{
rtp_hdr = (RTP_FIXED_HEADER*)&rtpPacket[0];
//TRACE("版本号 : %d\n", rtp_hdr->version);
//TRACE("结束标志位 : %d\n", rtp_hdr->marker);
//TRACE("负载类型:%d\n", rtp_hdr->payload);
//TRACE("包号 : %d \n", htons(rtp_hdr->seq_no));
//TRACE("时间戳 : %d\n", htonl(rtp_hdr->timestamp));
//TRACE("同步标识符 : %d\n", htonl(rtp_hdr->ssrc));
if(len > RTP_HEADER_LEN)
{
memcpy(buff + buffsize, rtpPacket+ RTP_HEADER_LEN, len - RTP_HEADER_LEN); //去掉RTP包头
buffsize += len - RTP_HEADER_LEN;
}
}
else
{
buffsize += len;
}
if(buffsize >= minsize) //收到的数据长度大于minsize则向下传递
{
bHasData = TRUE;
#ifdef _DEBUG
if(m_bRecordFile)
{
if(m_fp == NULL)
{
m_fp = CreateFile(szDumpFile, GENERIC_WRITE, 0, /*&saAttr*/NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
TRACE(_T("录制开始:%s \n"), szDumpFile);
}
if(m_fp != INVALID_HANDLE_VALUE)
{
DWORD dw;
::WriteFile(m_fp, buff, buffsize, &dw, NULL);
}
}
#endif
m_pDownStreamController->InputStreamData((BYTE*)buff, buffsize); //向下级组件传数据
buffsize = 0;
}
else
{
continue;
}
}//while
delete buff;
buff = NULL;
#ifdef _DEBUG
if(m_fp != INVALID_HANDLE_VALUE && m_fp != NULL)
::CloseHandle(m_fp);
#endif
上面这个循环里,CStreamSocket类检测UDP Socket是否有数据可读,如果有,则调用winsock api: receivefrom函数获得数据,并且它会根据类成员变量m_bRTP来判断是否要去掉RTP包头来得到真正的Payload数据,关键代码如下:
if(m_bRTP)
{
rtp_hdr = (RTP_FIXED_HEADER*)&rtpPacket[0];
//TRACE("版本号 : %d\n", rtp_hdr->version);
//TRACE("结束标志位 : %d\n", rtp_hdr->marker);
//TRACE("负载类型:%d\n", rtp_hdr->payload);
//TRACE("包号 : %d \n", htons(rtp_hdr->seq_no));
//TRACE("时间戳 : %d\n", htonl(rtp_hdr->timestamp));
//TRACE("同步标识符 : %d\n", htonl(rtp_hdr->ssrc));
if(len > RTP_HEADER_LEN)
{
memcpy(buff + buffsize, rtpPacket+ RTP_HEADER_LEN, len - RTP_HEADER_LEN); //去掉RTP包头
buffsize += len - RTP_HEADER_LEN;
}
}
注意,这个接收函数并不是一收到数据就向上层传递数据,因为RTP包的数据包一般很小(小于1400),所以我们可以凑齐多一点数据再往下传,这样可以提高处理效率。关键代码如下:
if(buffsize >= minsize) //收到的数据长度大于minsize则向下传递
{
bHasData = TRUE;
m_pDownStreamController->InputStreamData((BYTE*)buff, buffsize); //向下级组件传数据
buffsize = 0;
}
else
{
continue;
}
上面的m_pDownStreamController->InputStreamData函数做了哪些工作呢?这里的 m_pDownStreamController指针变量其实是CMainFrame *类型,它指向的对象是应用程序的父窗口。让我们看看InputStreamData怎么实现的:
//传入PS or TS包数据
BOOL CMainFrame:: InputStreamData(PBYTE pBuf, DWORD Buflen)
{
if(m_PlaySDKHandle != NULL)
{
return PT_AddStreamData(m_PlaySDKHandle, pBuf, Buflen);
}
return FALSE;
}
它其实调用了SDK的接口函数PT_AddStreamData向SDK插入PS数据。
接着,我们看看SDK输出数据的地方:即回调函数,CMainFrame定义了一个接收ES帧数据的回调函数,函数如下:
//回调ES帧数据,分视频帧和音频帧,
int WINAPI SDKEsFrameCallback(int handle, BYTE * pBuf, int nBufSize, int nTrackNum, int nStreamType,__int64 llPts, int nFrameType)
{
if(gpMainFrame->m_nVStreamID == nTrackNum) //视频
{
TRACE("nTrackNum = %d, Data Len = %d, Pts = %I64d, nFrameType = %d \n", nTrackNum, nBufSize, llPts, nFrameType);
#if 1
gpMainFrame->PushPacket(pBuf, nBufSize, llPts, nFrameType);
#else
gpMainFrame->OnVideoFrame(pBuf, nBufSize, nFrameType, llPts);
#endif
if(m_bRecording) //是否保存分离出来的ES流(.h264, .mp4v, .mpeg2)
{
if(m_fp != INVALID_HANDLE_VALUE)
{
DWORD dw;
::WriteFile(m_fp, pBuf, nBufSize, &dw, NULL);
}
}
}
else//音频
{
if(gpMainFrame->m_nAudioType == AUDIO_DEFAULT) //未初始化音频格式
{
int nATrackNo = 0;
AudioEncodeFormat aFormat;
if(PT_GetAudioInfo(handle, aFormat, nATrackNo))
{
gpMainFrame->m_nAudioType = aFormat;
}
}
}
return 0;
}
上面的回调函数对视频和音频做了分开处理,区别的标志是通过nTrackNum参数,就是流的ID号。
对于视频流,上面还调用了一个子函数PushPacket,这个函数的作用就是把ES数据扔到视频队列里。PushPacket函数的代码如下:
void CMainFrame::PushPacket(BYTE * pBuf, int nBufSize, __int64 llPts, int nFrameType)
{
m_cs.Lock();
int nPkgListLen = gpMainFrame->m_videopacketList.size();
m_cs.Unlock();
#ifdef _READ_STREAM_FROM_FILE
if(nPkgListLen > MAX_PACKET_COUNT) //太多包要处理,等待一下
{
for(int i = 0; i< nPkgListLen/2; i++)
{
if(!m_decoding_thread_run)
break;
Sleep(20);
}
}
#else
// if(nPkgListLen > MAX_PACKET_COUNT) //太多包要处理
//{
// //扔掉这些包
// ReleasePackets();
// return;
//}
#endif
//将收到的包放到队列
PacketNode_t temNode;
temNode.length = nBufSize;
temNode.buf = new uint8_t[nBufSize];
memcpy(temNode.buf, pBuf, nBufSize);
temNode.pts = llPts;
temNode.frameType = nFrameType;
m_cs.Lock();
m_videopacketList.push_back(temNode);
m_cs.Unlock();
}
上面是往队列里写数据,那么读数据包呢? 读队列是位于解码视频的线程里:
DWORD WINAPI CMainFrame::decoding_thread(void* param)
{
TRACE("decoding_thread began! \n");
CMainFrame * pThisDlg = (CMainFrame*)param;
PacketNode_t pkt;
while(pThisDlg->m_decoding_thread_run)
{
int nRet = pThisDlg->ReadVideoPacket(&pkt);
if(nRet <= 0)
{
Sleep(2);
continue;
}
pThisDlg->OnVideoFrame(pkt.buf, pkt.length, pkt.frameType, pkt.pts); //视频
delete[] pkt.buf; //释放内存
pkt.buf = NULL;
}
return 0;
}
int CMainFrame::ReadVideoPacket(PacketNode_t * pOutNode)
{
int nsize = 0;
m_cs.Lock();
#ifdef _DEBUG
static DWORD dwLastTick = GetTickCount();
if(GetTickCount() - dwLastTick > 2000)
{
TRACE("视频队列长度: %d \n", m_videopacketList.size());
dwLastTick = GetTickCount();
}
#endif
if (!m_videopacketList.empty())
{
list<PacketNode_t>::iterator itr = m_videopacketList.begin();
if (itr != m_videopacketList.end())
{
*pOutNode = *itr; //复制Packet成员变量
nsize = itr->length;
//delete[] itr->buf; //释放内存
m_videopacketList.pop_front();
//break;
}
else
{
ASSERT(0);
}
}
else
{
nsize = -1; //表示没有数据可读
}
m_cs.Unlock();
return nsize;
}
基本上所有的关键流程都讲完了。
做这个实时流播放器耗费了作者一个星期的时间,虽然是业余时间做,但平均每天基本上花2-3个钟在开发上面,代码改了一遍又一遍,解决了N多个Bug。虽然自己接触PS格式已经有几年了,之前在公司也做过PS拆包、分离的工作,但是之前写的东西一直很不完善,自己觉得掌握的知识也不够深刻,现在做出一个比较完善的、自己满意的PS流实时流播放器,自己还是挺有成就感的,哈哈!
多谢大家阅读到这里,最后贴上这个例子的下载地址:https://download.csdn.net/download/zhoubotong2012/11971737
(下载说明:该例子只有界面程序的代码,PS包处理(即SDK)的代码没有开源,只有DLL接口,大家下载前请谨慎考虑!)
转载自原文链接, 如需删除请联系管理员。
原文链接:国标MPEG-PS实时流播放器开发(附例子),转载请注明来源!