多媒体科技音视频开发集锦音视频开发

带问题重读ijkPlayer

2018-10-25  本文已影响11人  FindCrt

问题

  1. 主流程上的区别
  2. 缓冲区的设计
  3. 内存管理的逻辑
  4. 音视频播放方式
  5. 音视频同步
  6. seek的问题:缓冲区flush、播放时间显示、k帧间距大时定位不准问题...
  7. stop时怎么释放资源,是否切换到副线程?
  8. 网络不好时的处理,如获取frame速度慢于消耗速度时,如果不暂停,会一致卡顿,是否会主动暂停?
  9. VTB的解码和ffmpeg的解码怎么统一的?架构上怎么设计的?

数据流向

主流程更详细看ijkPlayer主流程分析

音频
视频

读取packet部分一样

结论:主流程上没有大的差别。

缓冲区的设计

packetQueue:

  1. 数据机构设计

packetQueue采用两条链表,一个是保存数据的链表,一个是复用节点链表,保存没有数据的那些节点。数据链表从first_pktlast_pkt,插入数据接到last_pkt的后面,取数据从first_pkt拿。复用链表的开头是recycle_pkt,取完数据后的空节点,放到空链表recycle_pkt的头部,然后这个空节点成为新的recycle_pkt。存数据时,也从recycle_pkt复用一个节点。

链表的节点像是包装盒,装载数据的时候放到数据链表,数据取出后回归到复用链表。

  1. 进出的阻塞控制

取数据的时候可能没有,那么就有几种处理:直接返回、阻塞等待。它这里的处理是阻塞等待,并且会把视频播放暂停。所以这个回答了问题8,外面看到的效果就是:网络卡的时候,会停止播放然后流畅的播放一会,然后又继续卡顿,播放和卡顿是清晰分隔的。

进数据的时候并没有做阻塞控制,为什么数据不会无限扩大?

是有阻塞的,但阻塞不是在packetQueue里面,而是在readFrame函数里:

if (ffp->infinite_buffer<1 && !is->seek_req &&
             (is->audioq.size + is->videoq.size + is->subtitleq.size > ffp->dcc.max_buffer_size
           || (   stream_has_enough_packets(is->audio_st, is->audio_stream, &is->audioq, MIN_FRAMES)
               && stream_has_enough_packets(is->video_st, is->video_stream, &is->videoq, MIN_FRAMES)
               && stream_has_enough_packets(is->subtitle_st, is->subtitle_stream, &is->subtitleq, MIN_FRAMES)))) {
           if (!is->eof) {
               ffp_toggle_buffering(ffp, 0);
           }
           /* wait 10 ms */
           SDL_LockMutex(wait_mutex);
           SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10);
           SDL_UnlockMutex(wait_mutex);
           continue;
       }

简化来看就是:

因为个数设置到了50000,一般达不到,而是数据大小做了限制,在15M左右。

这里精髓的地方有两点:

frameQueue:

数据使用一个简单的数组保存,可以把这个数据看成是环形的,然后也是其中一段有数据,另一段没有数据。rindex表示数据开头的index,也是读取数据的index,即read index,windex表示空数据开头的index,是写入数据的index,即write index。

也是不断循环重用,然后size表示当前数据大小,max_size表示最大的槽位数,写入的时候如果size满了,就会阻塞等待;读取的时候size为空,也会阻塞等待。

有个奇怪的东西是rindex_shown,读取的时候不是读的rindex位置的数据,而是rindex+rindex_shown,需要结合后面的使用情况再看这个的作用。后面再看。

还有serial没有明白什么意思

结论:缓冲区的设计和我的完全不同,但都使用重用的概念,而且节点都是包装盒,数据包装在节点里面。性能上不好比较,但我的设计更完善,frame和packet使用统一设计,还包含了排序功能。

内存管理
  1. packet的管理

需要注意的一点是:avcodec_send_packet返回EAGAIN表示当前还无法接受新的packet,还有frame没有取出来,所以有了:

d->packet_pending = 1;
av_packet_move_ref(&d->pkt, &pkt);

把这个packet存到d->pkt,在下一个循环里,先取frame,再把packet接回来,接着上面的操作:

if (d->packet_pending) {
    av_packet_move_ref(&pkt, &d->pkt);
    d->packet_pending = 0;
}

可能是存在B帧的时候会这样,因为B帧需要依赖后面的帧,所以不会解码出来,等到后面的帧传入后,就会有多个帧需要读取。这时解码器应该就不接受新的packet。但ijkplayer这里的代码似乎不会出现这样的情况,因为读取frame不是一次一个,而是一次性读到报EAGAIN错误未知。待考察。

另,av_packet_move_ref这个函数就是完全的只复制,source的值完全的搬到destination,并且把source重置掉。其实就是搬了个位置,buf的引用数不改变。

  1. 视频frame的内存管理

因为这里只是拿到frame,然后存入缓冲区,还没有到使用的时候,如果buf被释放了,那么到播放的时候,数据就丢失了,所以是怎么处理的呢?

存入缓冲区在queue_picture里,再到SDL_VoutFillFrameYUVOverlay,这个函数会到上层,根据解码器不同做不同处理,以ijksdl_vout_overlay_ffmpeg.cfunc_fill_frame为例。

有两种处理:

  1. 音频frame的处理

audio_thread里,不断通过decoder_decode_frame获取到新的frame。和视频一样,这里的frame也是对内存,读到解码后的frame后,引用为1。音频的格式转换放在了播放阶段,所以这里只是单纯的把frame存入:av_frame_move_ref(af->frame, frame);。做了一个复制,把读取的frame搬运到缓冲区里了。

在frame的缓冲区取数据的时候,frame_queue_next里包含了av_frame_unref把frame释放。这个视频也是一样

有一个问题是,上层播放器的读取音频数据的时候,frame必须是活的,因为如果音频不转换格式,是直接读取了frame里的数据。所以也就是需要在填充播放器数据结束后,才可以释放frame。

unref是在frame_queue_next,而这个函数是在下一次读取frame的时候才发生,下一次读取frame又是在当前的数据读完后,所以读完了数据后,才会释放frame,这样就没错了。

//数据读完才会去拉取下一个frame
if (is->audio_buf_index >= is->audio_buf_size) {
    audio_size = audio_decode_frame(ffp);
音视频的播放方式

音频播放使用AudioQueue:

上面这些都是AudioQueue的标准操作,特别的是构建AudioStreamBasicDescription的时候,也就是指定音频播放的格式。格式是由音频源的格式决定的,在IJKSDLGetAudioStreamBasicDescriptionFromSpec里看,除了格式固定为pcm之外,其他的都是从底层给的格式复制过来。这样就有了很大的自由,音频源只需要解码成pcm就可以了。

而底层的格式是在audio_open里决定的,逻辑是:

因为都是pcm,是不压缩的音频,所以决定性的因素就只有:采样率、通道数和样本格式。样本格式固定s16,和上层沟通就是决定采样率和通道数。

这里是一个很好的分层架构的例子,底层通用,上层根据平台各有不同。

视频的播放:

播放都是使用OpenGL ES,使用IJKSDLGLView,重写了layerClass,把layer类型修改为CAEAGLLayer可以显示OpenGL ES的渲染内容。所有类型的画面都使用这个显示,有区别的地方都抽象到Render这个角色里了,相关的方法有:

render的构建包括:

render的绘制包括:

提供纹理的方法也是重点,区别在于颜色空间以及元素的排列方式:

glTexImage2D(GL_TEXTURE_2D,
                    0,
                    GL_RGBA,
                    widths[plane],
                    heights[plane],
                    0,
                    GL_RGBA,
                    GL_UNSIGNED_BYTE,
                    pixels[plane]);

构建纹理的时候区别就在format跟type参数上。

//3层的
yuv.y = (texture2D(us2_SamplerY, vv2_Texcoord).r - 0.5);
       yuv.z = (texture2D(us2_SamplerZ, vv2_Texcoord).r - 0.5);
//双层的
yuv.yz = (texture2D(us2_SamplerY,  vv2_Texcoord).rg - vec2(0.5, 0.5));

uv在同一个纹理里,texture2D直接取了rg两个分量。

ijkplayer里的的OpenGL ES是2.0版本,如果使用3.0版本,双通道可以使用GL_LUMINANCE_ALPHA

音视频同步

首先看音频,音频并没有做阻塞控制,上层的的播放器要需要数据都会填充,没有看到时间不到不做填充的操作。所以应该是默认了音频钟做主控制,所以音频没做处理。

1. 视频显示时的时间控制

视频的控制在video_refresh里,播放函数是video_display2,进入这里代表时间到了、该播了,这是一个检测点。

有几个参数需要了解:

if (isnan(is->frame_timer) || time < is->frame_timer){
    is->frame_timer = time;
}

上一帧的播放时间在当前时间后面,说明数据错误,调整到当期时间

if (time < is->frame_timer + delay) {
    *remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);
    goto display;
}

is->frame_timer + delay就表示当前帧播放的时间,这个时间晚于当前时间,就表示还没到播放的时候。

这个有个坑goto display并不是去播放了,因为display代码块里还有一个判断,判断里有个is->force_refresh。这个值默认是false,所以直接跳去display,实际的意义是啥也不干,结束这次判断。

反之,如果播放时间早于当前时间,那就要马上播放了。所以更新上一帧的播放时间:is->frame_timer += delay;

然后一直到后面,有个is->force_refresh = 1;,这时才是真的播放。

从上面两段就可以看出基本的流程了:

一开始当前帧播放时间没到,goto display等待下次循环,循环多次,时间不段后移,终于播放时间到了,播放当前帧,frame_timer更新为当前帧的时间。然后又重复上面的过程,去播放下一帧。

然后有个问题是:为什么frame_timer的更新是加上delay,而不是直接等于当前时间?

如果直接等于当前时间,因为time>= frame_timer+delay,那么frame_timer是相对更大了一些,那么在计算下一帧时间,也就是frame_timer+delay的时候,也就会大一点。而且每一帧都会是这个情况,最后每一帧都会大那么一点,整体而言可能会有有比较大的差别。

if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX){
    is->frame_timer = time;
}

在frame_timer比较落后的时候,直接提到当前time上,就可以直接把状态修正,之后的播放又会走上正轨。

2. 同步钟以及钟时间的修正

同步钟的概念: 音频或者视频,如果把内容正确的完整的播放,某个内容和一个时间是一一对应的,当前的音频或者视频播放到哪个位置,它就有一个时间来表示,这个时间就是同步钟的时间。所以音频钟的时间表示音频播放到哪个位置,视频钟表示播放到哪个位置。

因为音频和视频是分开表现的,就可能会出现音频和视频的进度不一致,在同步钟上就表现为两个同步钟的值不同,如果让两者统一,就是音视频同步的问题。

因为有了同步钟的概念,音视频内容上的同步就可以简化为更准确的:音频钟和视频钟时间相同。

这时会有一个同步钟作为主钟,也就是其他的同步钟根据这个主钟来调整自己的时间。满了就调快、快了就调慢。

compute_target_delay里的逻辑就是这样,diff = get_clock(&is->vidclk) - get_master_clock(is);这个是视频钟和主钟的差距:

//视频落后超过临界值,缩短下一帧时间
if (diff <= -sync_threshold)
    delay = FFMAX(0, delay + diff);
//视频超前,且超过临界值,延长下一帧时间
else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)
    delay = delay + diff;
else if (diff >= sync_threshold)
    delay = 2 * delay;

至于为什么不都是delay + diff,即为什么还有第3种1情况,我的猜测是:

延时直接加上diff,那么下一帧就直接修正了视频种和主钟的差异,但有可能这个差异已经比较大了,直接一步到位的修正导致的效果就是:画面有明显的停顿,然后声音继续播,等到同步了视频再恢复正常。

而如果采用2*delay的方式,是每一次修正delay,多次逐步修正差异,可能变化上会更平滑一些。效果上就是画面和声音都是正常的,然后声音逐渐的追上声音,最后同步。

至于为什么第2种情况选择一步到位的修正,第3种情况选择逐步修正,这个很难说。因为AV_SYNC_FRAMEDUP_THRESHOLD值为0.15,对应的帧率是7左右,到这个程度,视频基本都是幻灯片了,我猜想这时逐步修正也没意义了。

3. 同步钟时间获取的实现

再看同步钟时间的实现:get_clock获取时间, set_clock_at更新时间。

解析一下:return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);,为啥这么写?

上一次显示的时候,更新了同步钟,调用set_clock_at,上次的时间为c->last_updated,则:

c->pts_drift + time = (c->pts - c->last_updated)+time;

假设距离上次的时间差time_diff = time - c->last_updated,则表达式整体可以变为:

c->pts+time_diff+(c->speed - 1)*time_diff,合并后两项变为:
c->pts+c->speed*time_diff.

我们要求得就是当前时间时的媒体内容位置,上次的位置是c->pts,而中间过去了time_diff这么多时间,媒体内容过去的时间就是:播放速度x现实时间,也就是c->speed*time_diff。举例:现实里过去10s,如果你2倍速的播放,那视频就过去了20s。所以这个表达式就很清晰了。

set_clock_speed里同时调用了set_clock,这是为了保证从上次更新时间以来,速度是没变的,否则计算就没有意义了。

到这差不多了,还有一点是在seek时候同步钟的处理,到seek问题的时候再看。

seek的处理

seek就是调整进度条到新的地方开始播,这个操作会打乱原本的数据流,一些播放秩序要重新建立。需要处理的问题包括:

流程

  1. 外界seek调用到ijkmp_seek_to_l,然后发送消息ffp_notify_msg2(mp->ffplayer, FFP_REQ_SEEK, (int)msec);,消息捕获到后调用到stream_seek,然后设置seek_req为1,记录seek目标到seek_pos
  2. 在读取函数read_thread里,在is->seek_req为true时,进入seek处理,几个核心处理:

到这里值得学习的点是:

因为

if (pkt == &flush_pkt)
        q->serial++;

所以serial的意义就体现出来了,每次seek,serial+1,也就是serial作为一个标记,相同代表是同一次seek里的。

  1. decoder_decode_frame里:

这一段厉害在:

  1. 播放处

视频video_refresh里:

   if (vp->serial != is->videoq.serial) {
       frame_queue_next(&is->pictq);
       goto retry;
   }

音频audio_decode_frame里:

    do {
       if (!(af = frame_queue_peek_readable(&is->sampq)))
           return -1;
       frame_queue_next(&is->sampq);
    } while (af->serial != is->audioq.serial);

都根据serial把旧数据略过了。

所以整体看下来,seek体系里最厉害的东西的东西就是使用了serial来标记数据,从而可以很明确的知道哪些是就数据,哪些是新数据。然后处理都是在原线程里做的处理,而不是在另外的线程里来修改相关的数据,省去了线程控制、线程通讯的麻烦的操作,稳定性也提高了。

播放时间获取

ijkmp_get_current_position,seek时,返回seek的时间,播放时看ffp_get_current_position_l,核心就是内容时间get_master_clock减去开始时间is->ic->start_time

seek的时候,内容位置发生了一个巨大的跳跃,所以要怎么维持同步钟的正确?

set_clock_at(&is->audclk, 
is->audio_clock - (double)(is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec - SDL_AoutGetLatencySeconds(ffp->aout), 
is->audio_clock_serial, 
ffp->audio_callback_time / 1000000.0);

因为is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;,所以audio_write_buf_size就是当前帧还没读完剩余的大小,所以(double)(is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec就标识剩余的数据播放完的时间。

SDL_AoutGetLatencySeconds(ffp->aout)是上层的缓冲区的数据的时间,对iOS的AudioQueue而言,有多个AudioBuffer等待播放,这个时间就是它们播放完要花的时间。

时间轴上是这样的:

[帧结束点][剩余buf时间][上层的buf时间][刚结束播放的点]

所以第二个参数的时间是:当前帧结束时的内容时间-剩余buf的时间-上层播放器buf的时间,也就是刚结束播放的内容时间。

ffp->audio_callback_time是填充方法调用时的时间,这里存在一个假设,就是上层播放器播完了一个buffer,立马调用了填充函数,所以ffp->audio_callback_time就是刚结束播放的现实时间。

这样第2个参数和第4个参数的意义就匹配上了。

回到seek,在seek完成后,会有第一个新的frame进入播放,它会把同步钟的pts,也就是媒体的内容时间调整到seek后的位置,那么还有一个问题:mp->seek_req这个标识重置回0的时间点必须比第一个新frame的set_clock_at要晚,否则同步钟的时间还没调到新的,seek的标识就结束了,然后根据同步钟去计算当前的播放时间,就出错了(界面上应该是进度条闪回seek之前)。

而事实上并没有这样,因为在同步钟的get_clock,还有一个

if (*c->queue_serial != c->serial)
        return NAN;

这个serial真是神操作,太好用了!

音频钟和视频钟的serial都是在播放时更新的,也就是第一帧新数据播放时更新到seek以后的serial,而c->queue_serial是一个指针:init_clock(&is->vidclk, &is->videoq.serial);,和packetQueue的serial共享内存的。

所以也就是到第一帧新数据播放后,c->queue_serial != c->serial这个才不成立。也就是即使mp->seek_req重置回0,取得值还是seek的目标值,还不是根据pts计算的,所以也不会闪回了。

关于seek的东西太复杂了。

stop时的资源释放

从方法shutdown到核心释放方法stream_close。操作的流程如下:

  1. 停掉读取线程:
  1. 停掉解码器部分stream_component_close
  1. 停掉显示线程:在显示线程里有判断数据流,视频is->video_st,音频is->audio_st,在上一步里把流重置为空,显示线程会结束。这里同样使用SDL_WaitThread等待线程结束。
  2. 清空缓冲区数据:packet_queue_destroy销毁packetQueue,frame_queue_destory销毁frameQueue。

对比我写的,需要修改的地方:

核心就是第一点,使用pthread_join等待线程结束。

网络不好处理

会自动暂停,等待。内部可以控制播放或暂停。

使用VTB时架构的统一
  1. frame缓冲区使用自定义的数据结构Frame,通过他可以把各种样式进行统一。
  2. 下层拥有了Frame数据,上层的对接对象时Vout,边界就在这里。然后上层要的是overlay,所以问题就是怎么由frame转化成overlay,以及如何显示overlay。这两个操作由Vout提供的create_overlaydisplay_overlay来完成。
  3. 使用VTB之后,数据存在解码后获得的pixelBuffer里,而ffmpeg解码后的数据在AVFrame里,这个转化的区别就在不同的overlay创建函数里。

总结:

上一篇下一篇

猜你喜欢

热点阅读