如果说,视频的解码是最核心的一步,那么视频的显示播放,就是最复杂的一步,也是最难的一步。
接着上一篇文章的激情,这一篇文章主要是讲述解码后的数据是怎么有顺序,有规律地显示到我们的手机屏幕上的。
基于iOS平台的最简单的FFmpeg视频播放器(一)
基于iOS平台的最简单的FFmpeg视频播放器(二)
基于iOS平台的最简单的FFmpeg视频播放器(三)
正式开始
- 视频数据显示的步骤和原理,这里我们需要好好地理一理思路。
1.先初始化一个基于OpenGL
的显示的范围。
2.把准备显示的数据处理好(就是上一篇文章没有讲完的那个部分)。
3.在OpenGL
上绘制一帧图片,然后删除数组中已经显示过的帧。
4.计算数组中剩余的还没有解码的帧,如果不够了那就继续开始解码。
5.通过一开始处理过的数据中获取时间戳,通过定时器控制显示帧率,然后回到步骤3。
6.所有的视频都解码显示完了,播放结束。
1.准备活动
1.1 初始化OpenGL的类
- 接下来我们使用
AieGLView
类,都是仿照自Kxmovie
中的KxMovieGLView
类。里面的具体实现内容比较多,以后我们单独分出一个模块来讲。
- (void)setupPresentView
{
_glView = [[AieGLView alloc] initWithFrame:CGRectMake(0, self.view.frame.size.height - 200, 300, 200) decoder:_decoder];
[self.view addSubview:_glView];
self.view.backgroundColor = [UIColor clearColor];
}
1.2 处理解码后的数据
- 这里就是上一篇文章中,解码结束之后,应该对数据做的处理。
- (AieVideoFrame *)handleVideoFrame
{
if (!_videoFrame->data[0]) {
return nil;
}
AieVideoFrame * frame;
if (_videoFrameFormat == AieVideoFrameFormatYUV) {
AieVideoFrameYUV * yuvFrame = [[AieVideoFrameYUV alloc] init];
yuvFrame.luma = copyFrameData(_videoFrame->data[0],
_videoFrame->linesize[0],
_videoCodecCtx->width,
_videoCodecCtx->height);
yuvFrame.chromaB = copyFrameData(_videoFrame->data[1],
_videoFrame->linesize[1],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
yuvFrame.chromaR = copyFrameData(_videoFrame->data[2],
_videoFrame->linesize[2],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
frame = yuvFrame;
}
frame.width = _videoCodecCtx->width;
frame.height = _videoCodecCtx->height;
// 以流中的时间为基础 预估的时间戳
frame.position = av_frame_get_best_effort_timestamp(_videoFrame) * _videoTimeBase;
// 获取当前帧的持续时间
const int64_t frameDuration = av_frame_get_pkt_duration(_videoFrame);
if (frameDuration) {
frame.duration = frameDuration * _videoTimeBase;
frame.duration += _videoFrame->repeat_pict * _videoTimeBase * 0.5;
}
else {
frame.duration = 1.0 / _fps;
}
return frame;
}
- 以上的代码比较多,涉及的只是也比较广,所以我们还是一段一段的来分析。
1.2.1 AVFrame数据分析
if (!_videoFrame->data[0]) {
return nil;
}
-
_videoFrame
就是之前存储解码后数据的AVFrame
,之前我们只说到AVFrame
的定义,现在来说说它的结构。 -
AVFrame
有两个最重要的属性data
和linesize
。
1.data
是用来存储解码后的原始数据,对于视频来说就是YUV、RGB,对于音频来说就是PCM,顺便说一下,苹果手机录音出来的原始数据就是PCM。
2.linesize
是data数据中‘一行’数据的大小,一般大于图像的宽度。 -
data
其实是个指针数组,所以它存储的方式是随着数据格式的变化而变化的。
1.对于packed格式的数据(比如RGB24),会存到data[0]
中。
2.对于planar格式的数据(比如YUV420P),则会data[0]
存Y,data[1]
存U,data[2]
存V,数据的大小的比例也是不同的,朋友们可以了解下。
1.2.2 把数据封装成自己的格式
AieVideoFrame * frame;
if (_videoFrameFormat == AieVideoFrameFormatYUV) {
AieVideoFrameYUV * yuvFrame = [[AieVideoFrameYUV alloc] init];
yuvFrame.luma = copyFrameData(_videoFrame->data[0],
_videoFrame->linesize[0],
_videoCodecCtx->width,
_videoCodecCtx->height);
yuvFrame.chromaB = copyFrameData(_videoFrame->data[1],
_videoFrame->linesize[1],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
yuvFrame.chromaR = copyFrameData(_videoFrame->data[2],
_videoFrame->linesize[2],
_videoCodecCtx->width / 2,
_videoCodecCtx->height / 2);
frame = yuvFrame;
}
-
AieVideoFrame ,AieVideoFrameYUV
是我们自己定义的简单的类,不懂的可以去看代码,结构很简单。现在我们只考虑YUV的存储,暂时不考虑RGB。 - 上面的
luma, chromaB,chromaR
正好对应的YUV,从传进去的参数就可以发现。
static NSData * copyFrameData(UInt8 *src, int linesize, int width, int height)
{
width = MIN(linesize, width);
NSMutableData *md = [NSMutableData dataWithLength: width * height];
Byte *dst = md.mutableBytes;
for (NSUInteger i = 0; i < height; ++i)
{
memcpy(dst, src, width);
dst += width;
src += linesize;
}
return md;
}
- 说好的一行行的看代码就得一行行看,之前我们说过
linesize
中的一行的数据大小,一般情况下比实际宽度大一点,但是为了避免特殊情况,这里还是需要判断一下,取最小的那个。 - 下面就是把数据装到
NSMutableData
这个容器中,显而易见,数据的总大小就是width * height
。所以遍历的时候就遍历它的height
,然后把整个宽度的数据全部拷贝到目标容器中,由于这里是指针操作,所以我们需要把指针往后便宜到末尾,下一次拷贝的时候才可以继续从末尾添加数据。 - 有的朋友可能会问,为什么
dst
偏移的是width
, 但是src
偏移的是linesize
?理由还是之前的那一个linesize
可能会比width
大一点,我们的最终数据dst
应该根据width
来计算,但是src
(就是之前的data
)他的每一行实际大小是linesize
,所以才需要分开来偏移。
1.2.3 解码后数据的信息
frame.width = _videoCodecCtx->width;
frame.height = _videoCodecCtx->height;
// 以流中的时间为基础 预估的时间戳
frame.position = av_frame_get_best_effort_timestamp(_videoFrame) * _videoTimeBase;
// 获取当前帧的持续时间
const int64_t frameDuration = av_frame_get_pkt_duration(_videoFrame);
if (frameDuration) {
frame.duration = frameDuration * _videoTimeBase;
frame.duration += _videoFrame->repeat_pict * _videoTimeBase * 0.5;
}
else {
frame.duration = 1.0 / _fps;
}
-
AVCodecContext
中的长宽,才是视频的实际的长宽。 -
av_frame_get_best_effort_timestamp ()
是以AVFrame
中的时间为基础,预估的时间戳,然后乘以_videoTimeBase
(之前默认是0.25的那个),就是这个视频帧当先的时间位置。 -
av_frame_get_pkt_duration ()
是获取当前帧的持续时间。 - 接下来的一个if语句很刁钻,我也不是很理解,但是查了资料,大致是这样的。如何获取当前的播放时间:当前帧的显示时间戳 * 时基 + 额外的延迟时间,额外的延迟时间进入
repeat_pict
就会发现官方已经给我们了extra_delay = repeat_pict / (2*fps)
,转化一下其实也就是我们代码中的格式(因为fps = 1.0 / timeBase
)。
2. 开始播放视频
2.1 播放逻辑处理
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, 0.1 * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
[self tick];
});
- 上面代码的意思就是通过
GCD
的方式延迟0.1秒之后再开始显示,为什么要延迟0.1秒呢?因为这个一段代码是跟在解码视频的后面的,解码一帧视频也是需要时间的,所以需要延迟0.1秒。那么为什么是0.1秒呢?朋友们是否还记得上一篇文章中NSArray * frames = [strongDecoder decodeFrames:0.1];
,这里设置的最小的时间也是0.1秒,所以现在就可以共通了。
2.2 播放视频
- 做了这么多的铺垫,终于轮到我们的主角出场了,当当当。。。
- (void)tick
{
// 返回当前播放帧的播放时间
CGFloat interval = [self presentFrame];
const NSUInteger leftFrames =_videoFrames.count;
// 当_videoFrames中已经没有解码过后的数据 或者剩余的时间小于_minBufferedDuration最小 就继续解码
if (!leftFrames ||
!(_bufferedDuration > _minBufferedDuration)) {
[self asyncDecodeFrames];
}
// 播放完一帧之后 继续播放下一帧 两帧之间的播放间隔不能小于0.01秒
const NSTimeInterval time = MAX(interval, 0.01);
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^{
[self tick];
});
}
2.2.1 绘制图像
- (CGFloat)presentFrame
{
CGFloat interval = 0;
AieVideoFrame * frame;
@synchronized (_videoFrames) {
if (_videoFrames.count > 0) {
frame = _videoFrames[0];
[_videoFrames removeObjectAtIndex:0];
_bufferedDuration -= frame.duration;
}
}
if (frame) {
if (_glView) {
[_glView render:frame];
}
interval = frame.duration;
}
return interval;
}
-
@synchronized
是一个互斥锁,为了不让其他的线程同时访问锁中的资源。 - 线程里面的内容就很简单了,就是取出解码后的数组的第一帧,然后从数组中删除。
-
_bufferedDuration
就是数组中的数据剩余的时间的总和,所以取出数据之后,需要把这一帧的时间减掉。 - 如果第一帧存在,那就
[_glView render:frame]
,把视频帧绘制到屏幕上,这个函数涉及到OpenGL的很多知识,比较复杂,如果有朋友感兴趣的话,以后可以单独设一个模块仔细的讲一讲。
2.2.2 再次开始解码
const NSUInteger leftFrames =_videoFrames.count;
if (0 == leftFrames) {
return;
}
if (!leftFrames ||
!(_bufferedDuration > _minBufferedDuration))
{
[self asyncDecodeFrames];
}
- 当
_videoFrames
中已经没有可以播放的数据,说明视频已经播放完了,所以可以退出了,停止播放也可遵循一样的原理。 - 当剩余的时间
_bufferedDuration
小于_minBufferedDuration
时,那就继续开始解码。
2.2.3 播放下一帧
const NSTimeInterval time = MAX(interval, 0.01);
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, time * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^{
[self tick];
});
- 这个其实是一个
递归函数
,只是在中间加了一个正常的延时,两帧之间的播放间隔不能小于0.01秒,这样就可以达到我们看见的播放视频的效果了。
结尾
- 到这里我们关于最简单的视频播放器的内容就全部结束了,其实,我只是在
Kxmovie
的基础上,抽离出其中的核心代码,然后组成这样一系列的代码。如果反应好的话,我会继续把剩下完整的部分也陆续给大家分享出来的,谢谢大家的支持。 - 有兴趣的朋友也可以仔细的去解读
Kxmovie
的源码,如果文章中有错误的地方还希望大佬们可以指出。 - 由于放了FFmpeg库,所以Demo会很大,下载的时候比较费时。
- 谢谢阅读
网友评论