前几年负责过搜狐影音的播放器内核,这里主要记录、总结一下。
单个mp4分段是一个完整的mp4文件,为了播放一个mp4文件,需要:
由Demux进行音、视频Sample的解析,这里启动一个Demux线程,读取文件中的音、视频Sample,分别放入待解码音、视频队列。
线程函数:flyfox_player_mov_demux_read_cb
这个线程首先要解析mp4头,只有解析完mp4头才能进行后续的音、视频Sample的读取。下面通过读取视频Sample来展示需要哪些mp4头中的信息。
在缓存数据足够的情况下,读取视频Sample的过程:
序号 | Box | 字段 | 作用 |
---|---|---|---|
1. | stsc | First Chunk、Sample Per Chunk | 可以从Sample索引获得Chunk索引,以及Sample在Chunk内的索引。 |
2. | stco | Chunk Offset | 每个Chunk的Offset。 |
3. | stsz | Sample Size | 每个Sample的大小。 |
4. | avcC | AVC Decoder Configuration Record | 获得NALU长度以及ssp、pps等解码参数。 |
对音频Sample的读取来说就比较简单,直接通过stsc、stco、stsz获取到指定的Sample的偏移、大小,然后从文件中读取Sample即可,也就是只需要实现读取视频Sample的1~5步对应的操作,然后将读到的音频Sample直接放入待解码音频Sample队列。
这里使用ffmpeg进行音、视频的解码。
//注册所有编解码器。
avcodec_register_all();
//初始化AVPacket,作为输入参数。
AVPacket packet; //声明AVPacket。
av_init_packet(&packet); //输入Sample将传入packet。
//创建AVFrame,作为解码输出参数。
AVFrame* pFlyfoxAVFrame = avcodec_alloc_frame();
//找到H264解码器
AVCodec *pAVCodec = avcodec_find_decoder(AV_CODEC_ID_H264);
//创建AVCodecContext
AVCodecContext* pAVCodecContext = avcodec_alloc_context3(pAVCodec);
//打开解码器
avcodec_open2(pAVCodecContext, pAVCodec,0);
b) 解码
//设置输入参数
packet.data = src;
packet.size = size;
//解码
int got_pic = false;
avcodec_decode_video2(&pAVCodecContext,pFlyfoxAVFrame,&got_pic,&packet);
c) 拷贝数据
解码出来的YUV数据存放在AVFrame结构中,Pixel Format默认为YUV420P(I420P)类型,拷贝数据用到的成员如下:
//拷贝Y分量
for(i = 0; i < pFlyfoxAVFrame->height; i++)
{
pSrc = pFlyfoxAVFrame->data[0] + i * pFlyfoxAVFrame->linesize[0];
memcpy(pDst, pSrc, pFlyfoxAVFrame->width);
pDst += pFlyfoxAVFrame->width;
}
//拷贝U分量
for(i = 0; i< pFlyfoxAVFrame ->height/2; i++) //行数减半
{
pSrc = pFlyfoxAVFrame->data[1] + i * pFlyfoxAVFrame->linesize[1];
memcpy(pDst, pSrc, pFlyfoxAVFrame->width/2);
pDst += pFlyfoxAVFrame->width/2; //列数减半
}
//拷贝V分量
for(i = 0; i < g_pFlyfoxAVFrame->height/2; i++) //行数减半
{
pSrc = pFlyfoxAVFrame->data[2] + i * pFlyfoxAVFrame->linesize[2];
memcpy(pDst, pSrc, pFlyfoxAVFrame->width/2);
pDst += pFlyfoxAVFrame->width/2; //列数减半
}
a) 初始化:
//注册所有编解码器。
avcodec_register_all();
//初始化AVPacket,作为输入参数。
AVPacket packet; //声明AVPacket。
av_init_packet(&packet); //输入Sample将传入packet。
//创建AVFrame,作为解码输出参数。
AVFrame* pFlyfoxAVFrame = avcodec_alloc_frame();
//找到AAC解码器
AVCodec *pAVCodec = avcodec_find_decoder(CODEC_ID_AAC);
//创建AVCodecContext
AVCodecContext* pAVCodecContext = avcodec_alloc_context3(pAVCodec);
//打开解码器
avcodec_open2(pAVCodecContext, pAVCodec,0);
b) 解码
//设置输入参数
packet.data = src;
packet.size = size;
//解码
int got_audio = false;
avcodec_decode_audio4 (&pAVCodecContext,pFlyfoxAVFrame,&got_audio,&packet);
//如果是AV_SAMPLE_FMT_FLTP类型需要转换成AV_SAMPLE_FMT_S16
SwrContext *swr = NULL;
swr = swr_alloc_set_opts(swr,AV_SAMPLE_FMT_S16);
swr_init(swr);
if (pAVCodecContext->sample_fmt == AV_SAMPLE_FMT_FLTP)
{
swr_convert(swr,dst_buffer);
}
这里采用的是DirectShow框架来播放视频,在DirectShow框架中,所有的功能被封装在各个Filter中。DirectShow中提供了一些Render Filer来渲染视频,例如evr、vmr7、vmr9等,可以充分利用显卡进行硬件加速,达到比较好的显示效果。为了将已经解码的视频数据交给Render Filer,需要建立一个Source Filer,通过管脚将Source Filer和Render Filer进行连接,类似一个管道,在Source Filer的输出管脚上PushFrame时,Render Filer将能通过输入管脚获得视频数据,交给显卡渲染播放。
flyfoxDSFilter.dll实现了一个视频的Source Filer,作为一个COM组件,其实现了COM组件的相关接口,并实现了一个IFlyfoxDirectSrc接口,用于向外部提供Source Filter的相关功能,PushFrame就是其中一个方法,用于推送视频数据。
在VADSDisplay.dll中,封装了音、视频Render Filer的相关接口,并将Render Filter与Source Filter连接,解码器在解码得到裸数据后,播放线程调用VADSDisplayer的接口,将解码后的视频数据分别送入各自的Render Filer。
注意这里没有使用DirectShow的音频DirectSound Filter,而是直接调用DirectSound的接口,这样可以不用实现音频的Source Filter,同时更灵活运用DirectSound的特性。
这里采用的时钟源是音频流的时钟,因为声卡基本都有自己的时钟源。
在打开文件后,从文件头中获得了音频的码率,从而得到了音频数据偏移跟音频播放时间戳的关系:音频播放时间戳=音频数据偏移/音频码率。
这个播放时间在DirectShow中称为流时间(Stream Time),是描述相对于某个参考位置(如开始播放时间)的时间差。
音频播放会调用DirectSound的接口,DirectSound维护了当前播放的音频数据缓冲的播放偏移,因此可以通过上述公式换算成音频时间戳。
视频渲染线程首先获得这个音频时间戳ta,然后检查当前准备播放的视频Sample的时间戳tv,如果ta≥tv,则该视频Sample没有提前到来,应该立刻播放,如果时间差过大,则考虑丢弃;如果ta
搜狐视频的单个剧一般由多个分段构成,每个分段一般是5分钟,在播放一个剧的多个分段的过程中需要面临的一个问题是不同分段之前的切换,如果分段切换的过程中出现卡顿,则会影响用户的观看体验,这里采用了预加载下一分段的办法来解决这个问题。
在flyfox_demux_decoder_filter中维护一个Demux demux[2]的数组,分别表示播放中的和预加载中的Demux,每个Demux将处理不同的分段文件。Decoder与Demux是解耦的,使用一个nSourceFilterIndex索引来表示当前正在使用的Demux,在需要切换的时候,关闭掉当前播放的Demux后,nSourceFilterIndex设置为正在预加载的Demux的索引,Decoder只需要从nSourceFilterIndex指定的Demux获取数据即可。
注意预加载启动的时机,如果太快启动预加载则可能会造成浪费。比如用户可能看到分段中间位置就Seek到文件的最后一个分段,而不是下一个分段。这里采取的策略是在当前分段播放到最后30秒的时候启动下一个分段的预加载,这个时候可以认为用户期望观看下一分段。
在进行实际的mp4 seek之前有一些操作,因为seek的时间可能不在当前已经缓存的数据里面。
影音的seek是基于整剧时间的seek,由于一个剧是由多段文件组成,在进行实际的mp4文件seek前,需要将seek绝对时间换算成对应的分段i和分段内的时间偏移t,如果已经缓存了第i个分段mp4的完整数据,那么直接在这个分段内seek到时间t,否则需要下载第i个分段的数据。注意这里并不是下载整个分段的完整数据,而是下载seek到时间t后的分段mp4,对播控来说实际上是一个完整的mp4,但是并不是原始的完整分段,这样做可以减少mp4头的下载量,提高启播速度。播控需要维护这个偏移时间t,在下次seek的时候,如果定位到同一分段i的时间t1,并且t1>=t,那么可以直接在上次下载的mp4文件中seek到t1-t位置。下面需要判断数据是否足够,如果数据足够就直接在mp4文件内seek,如果不够的话,需要等待或者去请求t1开始的文件。
如果seek的时间被定位到预加载的分段里面,那么上述的时间判断可以省去,因为预加载的是完整的分段数据,这个时候只需要判断数据是否足够即可。
Seek的目标就是将时间换算成文件偏移offset,从offset开始的位置开始读取音、视频的Sample,达到修改播放时间的目的。
mp4文件内的seek跟mp4头关系紧密,需要解析mp4头中的若干box才能提供足够的信息。主要涉及的Box以及提供的信息如下:
序号 | Box | 字段 | 作用 |
---|---|---|---|
1 | mdhd | time scale | 1秒的时间单位数,其倒数就是时间单位; |
2 | stts | Sample delta | 每个Sample的时间单位数,Sample的时间=Sample delta / time scale; |
3 | stss | Sample number | 这个表存放视频关键帧的索引,可以通过该表得到距离指定Sample最近的关键帧Sample索引。 |
4 | stsc | First Chunk、Sample Per Chunk | 可以从Sample索引获得Chunk索引,以及Sample在Chunk内的索引。 |
5 | stco | Chunk Offset | 每个Chunk的Offset |
6 | stsz | Sample Size | 每个Sample的大小 |
视频Seek的步骤:
由于flv的音频、视频编码与mp4完全相同,所以复用解码的逻辑。实际上除了封装格式不同,flv播放的其他逻辑跟mp4播放基本相同(56的flv只有一个分段,比多段的mp4更好处理),因此只需要实现一个flv Demux来解析flv中的头、音频、视频数据。
flv的解析包括解析flv头和数据tag,实际上,flv除了9个字节的没有多少信息的固定头之外,所有数据都是由tag组成。flv有3种tag,分为Metadata Tag(或者叫脚本tag)、Video Tag、Audio Tag。其前3个tag比较特殊,基本可以认为是flv头,解析了这3个头,就可以进行正常的flv播放、seek。这个3个tag分别是:
Flv的seek操作与mp4有所区别,mp4的seek基于时间,将seek的绝对时间换算成分段号和分段内的时间偏移之后,向服务器请求某个分段某个时间偏移的mp4文件。这个操作成立的前提是:CDN支持这样的接口。
但是对flv来说CDN并没有这样的接口,只有基于Http Range请求的接口,也就是基于文件偏移。因此,flv的Seek需要播放引擎先获得flv seek依赖的完整信息,最主要的是关键帧列表,注意这个关键帧列表可能位于Script Tag中,但它并不是标准。如果没有关键帧列表,那么flv的seek将会变得低效,必须按照码率计算seek时间对应的文件偏移,这个偏移并不是准确的tag偏移,只是估计tag偏移,必须下载这个位置之前的flv数据,累加tag的偏移,直达指针位置首次超过seek的估计tag偏移,并且需要继续寻找关键帧对应的tag,这个时候才能完成一次seek。
幸好,绝大多数flv都遵守这么一个规则,在Script Tag中按照时间顺序存放了关键帧列表,这个列表中,每个字段包含了关键帧的时间和偏移,这样flv的Seek就只有一个简单的二分查表的过程,比mp4的seek更简单。
Seek过程:
在Source Filter和Render Filer中间增加一个VsFilter,由VsFilter实现所有的字幕叠加操作,并向外提供相关的接口。其主要原理:VsFilter通过获取输出的文字路径点集合,转化成形状,并光栅化成Bitmap像素,然后与输入的图像进行Alpha混合,达到字幕叠加的目的,实际上是图像叠加。所有的操作都是使用CPU进行计算,所以会明显增加CPU的开销。
官方老版本的VsFilter性能较差,这里使用的是第3方优化后的XyVsFilter,与官方版本的主要区别是进行了大量的缓存,省去一些重复性的渲染以降低CPU使用率。
主要流程:
上层的数据会写入一个临时文件,在加载完成后,该临时文件是一个完整的mp4分段文件,并在播放完成后删除。在Demux内部维护一个初始大小为2M的内存缓存,Demux从临时文件一次读64KB数据到这个缓存,每次解析mp4头、解析Sample的时候其数据源都直接来自这个内存缓存。
在播放DRM视频时,对缓存有了新的需求:
VMR9有3种模式:Window、Windowless、Renderless。
D3D对图像数据的处理流程:创建某个形状的顶点缓冲,并创建一个纹理,将图像数据拷贝到纹理上,然后将纹理贴图到顶点缓冲对应的形状,之后就是D3D内部的渲染过程。
CPlaneScene与CSphereScene的区别是CPlaneScene创建矩形顶点缓冲而CSphereScene创建球面顶点缓冲,分别对应普通视频播放和全景视频播放。
在全景视频播放时,图像一般是2:1的宽高比,可以完整覆盖在球面上,这样通过球面的扭曲可以将本身就扭曲的360°全景视频在视场内还原,另外通过响应鼠标、键盘等事件调整视点、透视矩阵,可以完整实现全景视频的播放以及旋转、拉近、拉远等交互。
本地播放视频全部通过DirectShow的Filter实现。
在DirectShow内部,Filter的连接一般由GraphBuilder自动完成,称为智能连接。但是在这里自定义了类似智能连接的过程,使用策略是:先使用自定义的的连接策略,如果自定义连接策略失败则使用DirectShow的智能连接。
自定义连接流程: