指导4:创建线程
1、概要
上一次我们使用SDL的函数来达到支持音频播放的效果。每当SDL需要音频时它会启动一个线程来调用我们提供的回调函数。现在我们对视频进行同样的处理。这样会使程序更加模块化和跟容易协调工作 - 尤其是当我们想往代码里面加入同步功能。那么我们要从哪里开始呢?
首先我们注意到我们的主函数处理太多东西了:它运行着事件循环,读取包和处理视频解码。所以我们将把这些东西分成几个部分:我们会创建一个线程来负责解包;这个包会叫如到队列里面,然后由相关的视频或者音频线程来读取这个包。音频线程之前已经按照我们的想法建立好了;由于我们需要自己来播放视频,因此创建视频线程会有点复杂。我们会把真正播放视频的代码放在主线程。不是仅仅在每次循环时显示视频,而是把视频播放整合到事件循环中。现在的想法是解码视频,把结果保存到另一个队列中,然后创建一个普通事件(FF_REFRESH_EVENT)加入到事件系统中,接着我们的事件循环不断检测这个事件。他将会在这个队列里面播放下一帧。这里有一个图来解释究竟发生了什么事情:
主要目的是通过使用SDL_Delay线程的事件驱动来控制视频的移动,我们可以控制下一帧视频应该在什么时间在屏幕上显示。当我们在下一个教程中添加视频的刷新时间控制代码,就可以使视频速度播放正常了。
2、简化代码
——> 我们同样会清理一些代码。我们有所有这些视频和音频编解码器的信息,我们将会加入队列和缓冲和所有其他的东西。所有致谢东西都是为了一个逻辑单元,也就是视频。所以我们创建一个大结构体来装载这些信息,我们把它叫做VideoState。
| typedef struct VideoState { |
|
AVFormatContext *pFormatCtx; |
|
int videoStream, audioStream; |
|
uint8_t audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2]; |
|
unsigned int audio_buf_size; |
|
unsigned int audio_buf_index; |
|
VideoPicture pictq[VIDEO_PICTURE_QUEUE_SIZE]; |
|
int pictq_size, pictq_rindex, pictq_windex; |
让我们来看一下我们看到了什么。首先,我们看到基本信息 - 视频和音频流的格式和参数,和相应的AVStream对象。然后我们看到我们把以下音频缓冲移动到这个结构体里面。这些音频的有关信息(音频缓冲,缓冲大小等)都在附近。我们已经给视频添加了另一个队列,也为解码的帧(保存为overlay)准备了缓冲(会用来作为队列,我们不需要一个花哨的队列)。VideoPicture是我们创造的(我们将会在以后看看里面有什么东西)。我们同样注意到结构体还分配指针给我们额外创建的线程,退出标志和视频的文件名。
——>现在就让我们回到主函数,看看如何修改我们的代码,首先设置VideoState结构体:
1 | int main( int argc, char *argv[]) { |
7 |
is = av_mallocz( sizeof (VideoState)); |
av_mallocz()函数会为我们申请空间而且初始化为全0。
——>然后我们要初始化为视频缓冲准备的锁(pictq)。因为一旦事件驱动调用我们的视频函数 - 视频函数会从pictq抽出预解码帧。同时,我们的视频解码器会把信息放进去 - 我们不知道那个动作会先发生。希望你认识到这是一个经典的竞争条件。所以我们要在开始任何线程前为其分配空间。同时让我们把文件名放到VideoState当中。
1 | pstrcpy(is->filename, sizeof (is->filename), argv[1]); |
3 | is->pictq_mutex = SDL_CreateMutex(); |
4 | is->pictq_cond = SDL_CreateCond(); |
pstrcpy(已过期)是ffmpeg中的一个函数,其对strncpy作了一些额外的检测
3、我们的第一个线程
让我们启动我们的线程使工作落到实处吧:
| schedule_refresh(is, 40);//某个特定的毫秒数后弹出FF_REFRESH_EVENT事件。这将会反过来调用事件队列里的视频刷新函数 |
| is->parse_tid = SDL_CreateThread(decode_thread, is); |
schedule_refresh是一个我们将要定义的函数。它的动作是告诉系统在某个特定的毫秒数后弹出FF_REFRESH_EVENT事件。这将会反过来调用事件队列里的视频刷新函数。但是现在,让我们分析一下SDL_CreateThread()。
SDL_CreateThread()做的事情是这样的 - 它生成一个新线程能完全访问原始进程中的内存,启动我们给的线程。它同样会运行用户定义数据的函数。在这种情况下,我们调用decode_thread()并与VideoState结构体连接。上半部分的函数没什么新东西;它的工作就是打开文件和找到视频流和音频流的索引。唯一不同的地方是把格式内容保存到我们的大结构体中。当我们找到流后,我们调用另一个我们将要定义的函数stream_component_open()。这是一个一般的分离的方法,自从我们设置很多相似的视频和音频解码的代码,我们通过编写这个函数来重用它们。
stream_component_open()函数的作用是找到我们的解码器,设置音频参数,保存重要信息到大结构体中,然后启动音频和视频线程。我们还会在这里设置一些其他参数,例如指定编码器而不是自动检测等等,下面就是代码:
| int stream_component_open(VideoState *is, int stream_index) { |
|
AVFormatContext *pFormatCtx = is->pFormatCtx; |
|
AVCodecContext *codecCtx; |
|
SDL_AudioSpec wanted_spec, spec; |
// -------------------------找到音视频流-------------------------// | |
|
if (stream_index < 0 || stream_index >= pFormatCtx->nb_streams) { |
|
codecCtx = pFormatCtx->streams[stream_index]->codec;//从AVFormatContext结构体中得到AVCodecContext |
|
if (codecCtx->codec_type == CODEC_TYPE_AUDIO) {//如果是音频,利用AVCodecContext结构体对对SDL_AudioSpec结构体wanted_spec进行设置 |
|
wanted_spec.freq = codecCtx->sample_rate; |
|
wanted_spec.callback = audio_callback; |
|
wanted_spec.userdata = is; |
|
if (SDL_OpenAudio(&wanted_spec, &spec) < 0) {//打开声音设备 |
|
fprintf (stderr, "SDL_OpenAudio: %s\n" , SDL_GetError()); |
|
codec = avcodec_find_decoder(codecCtx->codec_id);//找到解码器 |
|
if (!codec || (avcodec_open(codecCtx, codec) < 0)) { |
|
fprintf (stderr, "Unsupported codec!\n" ); |
|
switch (codecCtx->codec_type) { |
|
is->audioStream = stream_index; |
|
is->audio_st = pFormatCtx->streams[stream_index]; |
|
memset (&is->audio_pkt, 0, sizeof (is->audio_pkt)); |
|
packet_queue_init(&is->audioq);//初始化队列 |
|
SDL_PauseAudio(0);//开始播放音频,如果没有立即供给足够的数据,它会播放静音。 |
|
is->videoStream = stream_index; |
|
is->video_st = pFormatCtx->streams[stream_index]; |
|
packet_queue_init(&is->videoq); |
|
is->video_tid =SDL_CreateThread(video_thread, is); |
这跟以前我们写的代码几乎一样,只不过现在是包括音频和视频。注意到我们建立了大结构体来作为音频回调的用户数据来代替了aCodecCtx。我们同样保存流到audio_st和video_st。像建立音频队列一样,我们也增加了视频队列。主要是运行视频和音频线程。就像如下:
6 |
is->video_tid = SDL_CreateThread(video_thread, is); |
我们还记得之前SDL_PauseAudio()的作用,还有SDL_CreateThread()跟以前的用法一样。我们会回到video_thread()函数。
在这之前,让我们回到decode_thread()函数的下半部分。基本上就是一个循环来读取包和把它放到相应的队列中:
06 |
if (is->audioq.size > MAX_AUDIOQ_SIZE || |
07 |
is->videoq.size > MAX_VIDEOQ_SIZE) { |
11 |
if (av_read_frame(is->pFormatCtx, packet) < 0) { |
12 |
if (url_ferror(&pFormatCtx->pb) == 0) { |
20 |
if (packet->stream_index == is->videoStream) { |
21 |
packet_queue_put(&is->videoq, packet); |
22 |
} else if (packet->stream_index == is->audioStream) { |
23 |
packet_queue_put(&is->audioq, packet); |
25 |
av_free_packet(packet); |
这里没有新的东西,除了我们的音频和视频队列定义了一个最大值,还有我们加入了检测读取错误的函数。格式内容里面有一个叫做pb的ByteIOContext结构体。ByteIOContext十一个保存所有低级文件信息的结构体。url_ferror检测结构体在读取文件时石油出现某些错误。
经过我们的for循环,我们等待程序结束或者通知我们已经结束。这些代码指导我们如何推送事件 - 一些我们以后用来显示视频的东西。
|
event.type = FF_QUIT_EVENT; |
我们通过SDL定义的一个宏来获取用户事件的值。第一个用户事件应该分配给SDL_USEREVENT,下一个分配给SDL_USEREVENT + 1,如此类推。FF_QUIT_EVENT在SDL_USEREVENT + 2中定义。如果我们喜欢,我们同样可以传递用户事件,这里我们把我们的指针传递给了一个大结构体。最后我们调用SDL_PushEvent()。在我们的循环分流中,我们只是把SDL_QUIT_EVENT部分放进去。我们还会看到事件循环的更多细节;现在,只是保证当我们推送FF_QUIT_EVENT时,我们会得到它和quit值变为1。
4、获得帧:视频线程
准备好解码后,我们开启视频线程:从视频队列里面读取包,把视频解码为帧,然后调用queue_picture函数来把帧放进picture队列:
|
int
video_thread(
void
*arg)
{
|
|
VideoState *is = (VideoState *)arg; |
|
AVPacket pkt1, *packet = &pkt1; |
|
pFrame = avcodec_alloc_frame(); |
|
if
(packet_queue_get(&is->videoq, packet, 1) < 0)
{
|
|
len1 = avcodec_decode_video(is->video_st->codec, pFrame, &frameFinished, //视频解码 |
|
packet->data, packet->size); |
|
if
(queue_picture(is, pFrame) < 0)
{
|
大部分函数在这点上应该是相似的。我们已经把avcodec_decode_video函数移动到这里,只是替换了一些参数;例如,我们的大结构体里面有AVStream,所以我们从那里得到我们的编解码器。我们持续地从视频队列里面取包,知道某人告诉我们该结束或者我们遇到错误。
5、帧排队
一起来看看我们picture队列里面用来存储我们解码帧的函数pFrame。由于我们的picture队列是SDL overlay(大概是为了视频显示尽量少的计算),我们需要把转换的帧存储在picture队列里面 :
1 | typedef struct VideoPicture { |
我们的大结构体有缓冲来存储他们。然而,我们需要自己分配SDL_Overlay(注意到allocated标志用来标示我们是否已经分配了内存)。
使用这个队列我们需要两个指针 - 写索引和读索引。我们同样记录着缓冲里面实际上有多少图片。为了写队列,我们第一次要等待缓冲清空以保证有空间存储VideoPicture。然后我们检测我们是否为写索引申请了overlay。如果没有,我们需要申请一些空间。如果窗口的大小改变了,我们同样需要重新申请缓冲。然而,为了避免锁问题,我们不会在这里申请(我还不太确定为什么;我相信要避免在不同线程调用SDL overlay函数。)
|
int
queue_picture(VideoState *is, AVFrame *pFrame)
{
|
|
SDL_LockMutex(is->pictq_mutex); |
|
while (is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE && |
|
SDL_CondWait(is->pictq_cond, is->pictq_mutex); |
|
SDL_UnlockMutex(is->pictq_mutex); |
|
vp = &is->pictq[is->pictq_windex]; |
|
if (!vp->bmp ||vp->width != is->video_st->codec->width ||vp->height != is->video_st->codec->height) |
|
event.type = FF_ALLOC_EVENT; |
|
SDL_LockMutex(is->pictq_mutex); |
|
while
(!vp->allocated && !is->quit)
{
|
|
SDL_CondWait(is->pictq_cond, is->pictq_mutex); |
|
SDL_UnlockMutex(is->pictq_mutex); |
当我们想退出时,退出机制就像我们之前看到的那样处理。我们已经定义了FF_ALLOC_EVENT为SDL_USEREVENT。我们推送事件然后等待条件变量分配函数运行。
让我们来看看我们是怎么改变事件循环的:
6 |
alloc_picture(event.user.data1); |
记住event.user.data1就是我们的大结构体。这已经足够简单了。让我们来看看alloc_picture()函数:
01 | void alloc_picture( void *userdata) { |
03 |
VideoState *is = (VideoState *)userdata; |
06 |
vp = &is->pictq[is->pictq_windex]; |
09 |
SDL_FreeYUVOverlay(vp->bmp); |
12 |
vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width, |
13 |
is->video_st->codec->height, |
16 |
vp->width = is->video_st->codec->width; |
17 |
vp->height = is->video_st->codec->height; |
19 |
SDL_LockMutex(is->pictq_mutex); |
21 |
SDL_CondSignal(is->pictq_cond); |
22 |
SDL_UnlockMutex(is->pictq_mutex); |
你应该认识到我们已经把SDL_CreateYUVOverlay移动到这里。此代码现在应该是相当不言自明。记住我们把宽度和高度保存到VideoPicture里面,因为由于某些原因我们不想改变视频的尺寸。
好了,我们解决了所有东西,现在我们的YUV overlay已经分配好内存,准备接收图片了。让我们回到queue_picture来看看把帧复制到overlay当中,你应该记得这部分内容的:
01 | int queue_picture(VideoState *is, AVFrame *pFrame) { |
09 |
SDL_LockYUVOverlay(vp->bmp); |
11 |
dst_pix_fmt = PIX_FMT_YUV420P; |
14 |
pict.data[0] = vp->bmp->pixels[0]; |
15 |
pict.data[1] = vp->bmp->pixels[2]; |
16 |
pict.data[2] = vp->bmp->pixels[1]; |
18 |
pict.linesize[0] = vp->bmp->pitches[0]; |
19 |
pict.linesize[1] = vp->bmp->pitches[2]; |
20 |
pict.linesize[2] = vp->bmp->pitches[1]; |
23 |
img_convert(&pict, dst_pix_fmt, |
24 |
(AVPicture *)pFrame, is->video_st->codec->pix_fmt, |
25 |
is->video_st->codec->width, is->video_st->codec->height); |
27 |
SDL_UnlockYUVOverlay(vp->bmp); |
29 |
if (++is->pictq_windex == VIDEO_PICTURE_QUEUE_SIZE) { |
32 |
SDL_LockMutex(is->pictq_mutex); |
34 |
SDL_UnlockMutex(is->pictq_mutex); |
这部分的主要功能就是我们之前所用的简单地把帧填充到YUV overlay。最后把值加到队列当中。队列的工作是持续添加直到满,和里面有什么就读取什么。因此所有东西都基于is->pictq_size这个值,需要我们来锁住它。所以现在工作是增加写指针(有需要的话翻转它),然后锁住队列增加其大小。现在我们的读索引知道队列里面有更多的信息,如果队列满了,我们的写索引会知道的。
6、播放视频
这就是我们的视频线程!现在我们已经包裹起所有松散的线程,除了这个 - 还记得我们调用schedule_refresh()函数吗?让我们来看看它实际上做了什么工作:
2 | static void schedule_refresh(VideoState *is, int delay) { |
3 |
SDL_AddTimer(delay, sdl_refresh_timer_cb, is); |
SDL_AddTimer()是一个SDL函数,在一个特定的毫秒数里它简单地回调了用户指定函数(可选择携带一些用户数据)。我们用这个函数来计划视频的更新 - 每次我们调用这个函数,它会设定一个时间,然后会触发一个事件,然后我们的主函数会调用函数来从picture队列里拉出一帧然后显示它!
不过首先,让我们来触发事件。它会发送:
1 | static Uint32 sdl_refresh_timer_cb(Uint32 interval, void *opaque) { |
3 |
event.type = FF_REFRESH_EVENT; |
4 |
event.user.data1 = opaque; |
这里就是相似的事件推送。FF_REFRESH_EVENT在这里的定义是SDL_USEREVENT + 1。有一个地方需要注意的是当我们返回0时,SDL会停止计时器,回调将不再起作用。
现在我们推送FF_REFRESH_EVENT,我们需要在事件循环中处理它:
7 |
video_refresh_timer(event.user.data1); |
然后调用这个函数,将会把数据从picture队列里面拉出来:
01 | void video_refresh_timer( void *userdata) { |
03 |
VideoState *is = (VideoState *)userdata; |
07 |
if (is->pictq_size == 0) { |
08 |
schedule_refresh(is, 1); |
10 |
vp = &is->pictq[is->pictq_rindex]; |
13 |
schedule_refresh(is, 80); |
19 |
if (++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) { |
22 |
SDL_LockMutex(is->pictq_mutex); |
24 |
SDL_CondSignal(is->pictq_cond); |
25 |
SDL_UnlockMutex(is->pictq_mutex); |
28 |
schedule_refresh(is, 100); |
现在,这个函数就非常简单明了了:它会从队列里面拉出数据,设置下一帧播放时间,调用vidoe_display来使视频显示到屏幕中,队列计数值加1,然后减小它的尺寸。你会注意到我们没有对vp做任何动作,这里解析为什么:在之后,我们会使用访问时序信息来同步视频和音频。看看那个“这里的时序代码”的地方,我们会找到我们应该以读快的速度来播放视频的下一帧,然后把值放到schedule_refresh()函数里面。现在我们只是设了一个固定值80。技术上,你可以猜测和检验这个值,然后重编你想看的所有电影,但是 1.过一段时间它会变 2.这是很笨的方法。之后我们会回到这个地方。
我们已经差不多完成了;我们还剩下最后一样东西要做:播放视频!这里就是视频播放的函数:
01 | void video_display(VideoState *is) { |
10 |
vp = &is->pictq[is->pictq_rindex]; |
12 |
if (is->video_st->codec->sample_aspect_ratio.num == 0) { |
15 |
aspect_ratio = av_q2d(is->video_st->codec->sample_aspect_ratio) * |
16 |
is->video_st->codec->width / is->video_st->codec->height; |
18 |
if (aspect_ratio <= 0.0) { |
19 |
aspect_ratio = ( float )is->video_st->codec->width / |
20 |
( float )is->video_st->codec->height; |
23 |
w = (( int )rint(h * aspect_ratio)) & -3; |
26 |
h = (( int )rint(w / aspect_ratio)) & -3; |
28 |
x = (screen->w - w) / 2; |
29 |
y = (screen->h - h) / 2; |
35 |
SDL_DisplayYUVOverlay(vp->bmp, &rect); |
由于我们的屏幕尺寸可能为任何尺寸(我们设置为640x480,用户有方法可以重新设置尺寸),我们需要动态指出我们需要多大的一个矩形区域。所以首先我们需要指定我们视频的长宽比,也就是宽除以高的值。一些编解码器会有一个奇样本长宽比,也就是一个像素或者一个样本的宽高比。由于我们的编解码的长宽值是按照像素来计算的,所以实际的宽高比等于样本宽高比某些编解码器的宽高比为0,表示每个像素的宽高比为 1x1。然后我们把视频缩放到尽可能大的尺寸。这里的 & -3表示与 -3做与运算,实际上是让他们4字节对齐。然后我们把电影居中,然后调用SDL_DisplayYUVOverlay()。
那么结果怎样?我们做完了吗?我们仍然要重写音频代码来使用我们新的VideoStruct,但那只是琐碎的改变,你可以参考示例代码。最后我们需啊哟做的事情是改变ffmpeg内部的退出回调函数变为我们自己的退出回调函数。
| VideoState *global_video_state; |
| int decode_interrupt_cb( void ) { |
|
return (global_video_state && global_video_state->quit); |
我们在主函数里面设置global_video_state这个大结构体。
这就是了!让我们来编译它:
1 | sdl-config --cflags --libs |
2 | gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lSDL -lz -lm |
享受你的未同步电影吧!下一节我们会使视频播放器真正地工作起来。