此文档翻译国外dranger教程:

An ffmpeg and SDL Tutorial or How to Write a Video Player in Less Than 1000 Lines

因为原版文档许久未更新,翻译过程中有删除一部分已失效链接,并将FFmpeg结构体说明链接转嫁到雷霄骅博客中

FFmpeg是一个很好的库,可以用来创建视频应用或者生成特定的工具。FFmpeg几乎为你把所有的繁重工作都做了,比如解码、编码、复用和解复用。这使得多媒体应用程序变得容易编写。它是一个简单的,用C语言编写的,快速的并且能够解码几乎所有你能用到的格式,当然也包括编码多种格式。

唯一的问题是它的文档基本上是没有的。这就是为什么当我决定研究FFmpeg来弄清楚音视频应用程序是如何工作的过程中,我决定把这个过程用文档的形式记录并且发布出来作为初学指导的原因。

在FFmpeg工程中有一个示例的程序叫作ffplay。它是一个用C编写的利用FFmpeg来实现完整视频播放的简单播放器。这个系列教程将从 Martin Bohme 写的一个更新版本的原始教程开始(我借鉴了一些他的工作),基于Fabrice Bellard的ffplay,我将从那里开发一个可以使用的视频播放器。在每一个Tutorial中,我将介绍一个或者两个新的思想并且讲解我们如何来实现它。每一个Tutorial都会有一个C源文件,你可以下载,编译并沿着这条思路来自己做。源文件将向你展示一个真正的程序是如何运行,我们如何来调用所有的部件,也将告诉你在Tutorial中技术实现的细节并不重要。当我们结束这个系列教程的时候,我们将有一个少于1000行代码的可以工作的视频播放器。

在写播放器的过程中,我们将使用 SDL 来输出音频和视频。SDL是一个优秀的跨平台的多媒体库,被用在MPEG播放、模拟器和很多视频游戏中。你将需要下载并安装SDL开发库到你的系统中,以便于编译这个指导中的程序。

这篇指导适用于具有相当编程背景的人。至少应该懂得C并且知道队列和互斥量等概念。你应当了解基本的多媒体中的像波形一类的概念,但是你不必知道的太多,因为我将在这篇指导中介绍很多这样的概念。

本教程同样是旧风格的ASCII文本教程,你也可以获得压缩的教程和示例源码,或者仅仅只是示例源码。

欢迎给我发邮件到dranger@gmail.com,讨论关于程序问题、疑问、注释、思路、特性等任何的问题。

文章目录

  • Tutorial 01: 制作屏幕录像
    • 概要
    • 打开文件
    • 保存数据
    • 读取数据
  • Tutorial 02: 输出到屏幕
    • SDL和视频
    • 创建一个显示
    • 显示图像
    • 绘制图像
  • Tutorial 03: 播放音频
    • 音频
    • 设置音频
    • 队列
    • 意外情况
    • 将包加入队列
    • 读取包
    • 最后解码音频
  • Tutorial 04:创建线程
    • 概要
    • 简化代码
    • 我们的第一个线程
    • 得到帧:video_thread
    • 把帧队列化
    • 显示视频
  • Tutorial: 05 音视频同步
    • 警告
    • 音视频如何同步
    • PTS 和 DTS
    • 同步
    • 编码:获得帧的时间戳
    • 编码:使用PTS来同步
    • 同步:音频时钟
  • Tutorial 06: 音频同步
    • 概要
    • 生成视频时钟
    • 时钟抽象化
    • 同步音频
    • 修正样本数
  • Tutorial 07: Seeking
    • 处理seek命令
    • 刷新缓冲区
  • 结语
  • 参考

Tutorial 01: 制作屏幕录像

源代码:tutorial01.c

概要

电影文件有很多基本的组成部分。首先,文件本身被称为容器(Container),容器的类型决定了信息被存放在文件中的位置。AVI 和 Quicktime 格式就是容器的例子。接着,你有一组数据流(Streams),例如,你经常有的是一个音频流和一个视频流。(一个流只是一种想像出来的词语,用来表示一连串的通过时间来串连的数据元素)。在流中的数据元素被称为帧(Frame)。每个流是由不同的编码器(Codec) 来编码生成的。编解码器描述了实际的数据是如何被编码Coded和解码Decoded的,因此它的名字叫做CODEC。Divx和MP3就是编解码器的例子。接着从流中被读出来的叫做包(Packets)。包是一段数据,它包含了一段可以被解码成方便我们最后在应用程序中操作的原始帧的数据。根据我们的目的,每个包包含了完整的帧或者对于音频来说是许多格式的完整帧。

基本上来说,处理视频和音频流是很容易的:

10 从Video.avi文件中打开视频流

20 从视频流中读取包,并转码为帧

30 如果这个帧还不完整,返回20

40 处理帧

50 返回20

在这个程序中使用FFmpeg来处理多种媒体是相当容易的,虽然很多程序可能在对帧进行处理的时候非常的复杂。因此在这篇指导中,我们将打开一个文件,读取里面的视频流,而且我们对帧的操作将是把这个帧写到一个PPM文件中。

打开文件

首先,来看一下我们如何打开一个文件。使用FFmpeg,你必需先初始化这个库。

#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <ffmpeg/swscale.h>
...
int main(int argc, charg *argv[]) {
av_register_all();

这里注册了所有的文件格式和编解码器的库,所以它们将自动检测文件格式。注意你只需要调用av_register_all()一次,因此我们在主函数main()中来调用它。如果你喜欢,也可以只注册特定的格式和编解码器,但是通常你没有必要这样做。

现在我们可以真正的打开文件:

AVFormatContext *pFormatCtx = NULL;// Open video file
if(avformat_open_input(&pFormatCtx, argv[1], NULL, 0, NULL)!=0)return -1; // Couldn't open file

我们通过第一个参数来获得文件名。这个函数读取文件的头部并且把信息保存到我们给的AVFormatContext结构体中。最后三个参数用来指定特殊的文件格式,缓冲大小和格式参数,但如果把它们设置为空NULL或者0,libavformat将自动检测这些参数。

这个函数只是检测了文件的头部,所以接着我们需要检查在文件中的流的信息:

// Retrieve stream information
if(avformat_find_stream_info(pFormatCtx, NULL)<0)return -1; // Couldn't find stream information

这个函数为pFormatCtx->streams填充上正确的信息。我们引进一个手工调试的函数来看一下里面有什么:

// Dump information about file onto standard error
dump_format(pFormatCtx, 0, argv[1], 0);

现在pFormatCtx->streams仅仅是一组大小为pFormatCtx->nb_streams的指针,所以让我们先跳过它直到我们找到一个视频流(AVStream)。

int i;
AVCodecContext *pCodecCtxOrig = NULL;
AVCodecContext *pCodecCtx = NULL;// Find the first video stream
videoStream=-1;
for(i=0; i<pFormatCtx->nb_streams; i++)if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_VIDEO) {videoStream=i;break;}
if(videoStream==-1)return -1; // Didn't find a video stream// Get a pointer to the codec context for the video stream
pCodecCtx=pFormatCtx->streams[videoStream]->codec;

流中关于编解码器的信息就是被我们叫做"codec context"(编解码器上下文AVCodecContext)的东西。这里面包含了流中所使用的关于编解码器的所有信息,现在我们有了一个指向它的指针。但是我们必需要找到真正的编解码器(AVCodec)并且打开它:

AVCodec *pCodec = NULL;// Find the decoder for the video stream
pCodec=avcodec_find_decoder(pCodecCtx->codec_id);
if(pCodec==NULL) {fprintf(stderr, "Unsupported codec!\n");return -1; // Codec not found
}
// Copy context
pCodecCtx = avcodec_alloc_context3(pCodec);
if(avcodec_copy_context(pCodecCtx, pCodecCtxOrig) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context
}
// Open codec
if(avcodec_open2(pCodecCtx, pCodec)<0)return -1; // Could not open codec

注意:我们不能直接在视频流中使用AVCodecContext!所以我们必须使用avcodec_copy_context()方法将context复制到新的内存位置(当然,在我们为它分配内存之后);

保存数据

现在我们需要找到一个地方来保存帧(AVFrame):

AVFrame *pFrame = NULL;// Allocate video frame
pFrame=av_frame_alloc();

因为我们准备输出保存24位RGB色的PPM文件,我们必需把帧的格式从原来的转换为RGB。FFmpeg将为我们做这些转换。在大多数项目中(包括我们的这个)我们都想把原始的帧转换成一个特定的格式。让我们先为转换来申请一帧的内存:

// Allocate an AVFrame structure
pFrameRGB=av_frame_alloc();
if(pFrameRGB==NULL)return -1;

即使我们申请了一帧的内存,当转换的时候,我们仍然需要一个地方来放置原始的数据。我们使用avpicture_get_size来获得我们需要的大小,然后手工申请内存空间:

uint8_t *buffer = NULL;
int numBytes;
// Determine required buffer size and allocate buffer
numBytes=avpicture_get_size(PIX_FMT_RGB24, pCodecCtx->width,pCodecCtx->height);
buffer=(uint8_t *)av_malloc(numBytes*sizeof(uint8_t));

av_malloc是FFmpeg的malloc,用来实现一个简单的malloc的包装,这样来保证内存地址是对齐的(4字节对齐或者2字节对齐)。它并不能保护你不被内存泄漏,重复释放或者其它malloc的问题所困扰。

现在我们使用avpicture_fill来把帧和我们新申请的内存来结合。关于AVPicture的构成:AVPicture结构体是AVFrame结构体的子集――AVFrame结构体的开始部分与AVPicture结构体是一样的。

// Assign appropriate parts of buffer to image planes in pFrameRGB
// Note that pFrameRGB is an AVFrame, but AVFrame is a superset
// of AVPicture
avpicture_fill((AVPicture *)pFrameRGB, buffer, PIX_FMT_RGB24,pCodecCtx->width, pCodecCtx->height);

最后,我们已经准备好来从流中读取数据了。

读取数据

我们将要做的是通过读取包来读取整个视频流,然后把它解码成帧,最后转换格式并且保存。

struct SwsContext *sws_ctx = NULL;
int frameFinished;
AVPacket packet;
// initialize SWS context for software scaling
sws_ctx = sws_getContext(pCodecCtx->width,pCodecCtx->height,pCodecCtx->pix_fmt,pCodecCtx->width,pCodecCtx->height,PIX_FMT_RGB24,SWS_BILINEAR,NULL,NULL,NULL);i=0;
while(av_read_frame(pFormatCtx, &packet)>=0) {// Is this a packet from the video stream?if(packet.stream_index==videoStream) {// Decode video frameavcodec_decode_video2(pCodecCtx, pFrame, &frameFinished, &packet);// Did we get a video frame?if(frameFinished) {// Convert the image from its native format to RGBsws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pFrameRGB->data, pFrameRGB->linesize);// Save the frame to diskif(++i<=5)SaveFrame(pFrameRGB, pCodecCtx->width, pCodecCtx->height, i);}}// Free the packet that was allocated by av_read_frameav_free_packet(&packet);
}

这个循环过程是比较简单的:av_read_frame()读取一个包并且把它保存到AVPacket结构体中。注意我们仅仅申请了一个包的结构体 – FFmpeg为我们申请了内部的数据的内存并通过packet.data指针来指向它。这些数据可以在后面通过av_free_packet()来释放。函数avcodec_decode_video()把包转换为帧。然而当解码一个包的时候,我们可能没有得到我们需要的关于帧的信息。因此,当我们得到下一帧的时候,avcodec_decode_video()为我们设置了帧结束标志frameFinished。最后,我们使用 img_convert()函数来把帧从原始格式(pCodecCtx->pix_fmt)转换成为RGB格式。要记住,你可以把一个AVFrame结构体的指针转换为AVPicture结构体的指针。最后,我们把帧和高度、宽度信息传递给我们的SaveFrame函数。

Packets注释

从技术上讲一个包可以包含部分或者其它的数据,但是FFmpeg的解释器保证了我们得到的包Packets包含的要么是完整的要么是多种完整的帧

现在我们需要做的是让SaveFrame函数能把RGB信息写入到一个PPM格式的文件中。我们将生成一个简单的PPM格式文件,请相信,它是可以工作的。

void SaveFrame(AVFrame *pFrame, int width, int height, int iFrame) {FILE *pFile;char szFilename[32];int  y;// Open filesprintf(szFilename, "frame%d.ppm", iFrame);pFile=fopen(szFilename, "wb");if(pFile==NULL)return;// Write headerfprintf(pFile, "P6\n%d %d\n255\n", width, height);// Write pixel datafor(y=0; y<height; y++)fwrite(pFrame->data[0]+y*pFrame->linesize[0], 1, width*3, pFile);// Close filefclose(pFile);
}

我们做了一些标准的文件打开动作,然后写入RGB数据。我们一次向文件写入一行数据。PPM格式文件的是一种包含一长串的RGB数据的文件。如果你了解 HTML色彩表示的方式,那么它就类似于把每个像素的颜色头对头的展开,就像#ff0000#ff0000…一个充满红色的屏幕。(它被保存成二进制方式并且没有分隔符,但是你自己是知道如何分隔的)。文件的头部表示了图像的宽度和高度以及最大的RGB值的大小。
现在,回顾我们的main()函数。一旦我们开始读取完视频流,我们必需清理一切:

// Free the RGB image
av_free(buffer);
av_free(pFrameRGB);// Free the YUV frame
av_free(pFrame);// Close the codecs
avcodec_close(pCodecCtx);
avcodec_close(pCodecCtxOrig);// Close the video file
avformat_close_input(&pFormatCtx);return 0;

你会注意到我们使用av_free来释放我们使用avcode_alloc_fram和av_malloc来分配的内存。

上面的就是代码!下面,我们将使用Linux或者其它类似的平台,你将运行:

gcc -o tutorial01 tutorial01.c -lavutil -lavformat -lavcodec -lz -lavutil -lm

如果你使用的是老版本的FFmpeg,你可以去掉-lavutil参数:

gcc -o tutorial01 tutorial01.c -lavformat -lavcodec -lz -lm

大多数的图像处理函数可以打开PPM文件。可以使用一些电影文件来进行测试。

Tutorial 02: 输出到屏幕

源码:tutorial02.c

SDL和视频

我们将使用SDL将解码数据输出到屏幕。 SDL代表Simple Direct Layer,是一个优秀的多媒体库,它是跨平台的,可用于多个项目。 您可以在官方网站上获取该库,也可以下载支持您的操作系统的开发包。 您需要这些库来编译本教程的代码(以及其他代码)。

SDL库中有许多种方式来在屏幕上绘制图形,而且它有一个特殊的方式来在屏幕上显示图像――这种方式叫做YUV覆盖。YUV(从技术上来讲并不叫YUV而是叫做YCbCr)是一种类似于 RGB 方式的存储原始图像的格式。粗略的讲,Y是亮度分量,U和V是色度分量。(这种格式比RGB复杂的多,因为很多的颜色信息被丢弃了,而且你可以每2个Y有1个U和1个V)。SDL的YUV覆盖使用一组原始的YUV数据并且在屏幕上显示出他们。它可以允许4种不同的 YUV格式,但是其中的YV12是最快的一种。还有一个叫做YUV420P的YUV格式,它和YV12是一样的,除了U和V分量的位置被调换了以外。 420意味着它以4:2:0的比例进行了二次抽样,基本上就意味着1个颜色分量对应着4个亮度分量。所以它的色度信息只有原来的1/4。这是一种节省带宽的好方式,因为人眼感觉不到这种变化。在名称中的P表示这种格式是平面的――简单的说就是Y,U和V分量分别在不同的数组中。FFmpeg可以把图像格式转换为YUV420P,但是现在很多视频流的格式已经是YUV420P的了或者可以被很容易的转换成YUV420P格式。

于是,我们现在计划把Tutorial 01中的SaveFrame()函数替换掉,让它直接输出我们的帧到屏幕上去。但一开始我们必需要先看一下如何使用SDL库。首先我们必需先包含SDL库的头文件并且初始化它。

#include <SDL.h>
#include <SDL_thread.h>if(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) {fprintf(stderr, "Could not initialize SDL - %s\n", SDL_GetError());exit(1);
}

SDL_Init()函数告诉了SDL库,哪些特性我们将要用到。当然SDL_GetError()是一个用来手工除错的函数。

创建一个显示

现在我们需要在屏幕上的一个地方放上一些东西。在SDL中显示图像的基本区域叫做面surface。

SDL_Surface *screen;screen = SDL_SetVideoMode(pCodecCtx->width, pCodecCtx->height, 0, 0);
if(!screen) {fprintf(stderr, "SDL: could not set video mode - exiting\n");exit(1);
}

这就创建了一个给定高度和宽度的屏幕。下一个选项是屏幕的颜色深度――0表示使用和当前一样的深度。(这个在OS X系统上不能正常工作,原因请看源代码)
现在我们在屏幕上来创建一个YUV绘制区域以便于我们输入视频上去,并配置我们的SWSContext 将原始图像数据转换为YUV420:

SDL_Overlay     *bmp = NULL;
struct SWSContext *sws_ctx = NULL;bmp = SDL_CreateYUVOverlay(pCodecCtx->width, pCodecCtx->height,SDL_YV12_OVERLAY, screen);// initialize SWS context for software scaling
sws_ctx = sws_getContext(pCodecCtx->width,pCodecCtx->height,pCodecCtx->pix_fmt,pCodecCtx->width,pCodecCtx->height,PIX_FMT_YUV420P,SWS_BILINEAR,NULL,NULL,NULL);

正如前面我们所说的,我们使用YV12来显示图像。

显示图像

前面那些都是很简单的。现在我们需要来显示图像。让我们看一下是如何来处理完成后的帧的。我们将原来对RGB处理的方式,并且替换SaveFrame() 为显示到屏幕上的代码。为了显示到屏幕上,我们将先建立一个AVPicture结构体并且设置其数据指针和行尺寸来为我们的YUV绘制服务:

if(frameFinished) {SDL_LockYUVOverlay(bmp);AVPicture pict;pict.data[0] = bmp->pixels[0];pict.data[1] = bmp->pixels[2];pict.data[2] = bmp->pixels[1];pict.linesize[0] = bmp->pitches[0];pict.linesize[1] = bmp->pitches[2];pict.linesize[2] = bmp->pitches[1];// Convert the image into YUV format that SDL usessws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(bmp);}

首先,我们锁定这个绘制区域,因为我们将要去改写它。这是一个避免以后发生问题的好习惯。正如前面所示的,这个AVPicture结构体有一个数据指针指向一个有4个元素的指针数据。由于我们处理的是YUV420P,所以我们只需要3个通道即只要三组数据。其它的格式可能需要第四个指针来表示alpha通道或者其它参数。行尺寸正如它的名字表示的意义一样。在YUV覆盖中相同功能的结构体是像素pixel和程度pitch。(程度pitch是在SDL里用来表示指定行数据宽度的值)。所以我们现在做的是让我们的覆盖中的pict.data中的三个指针有一个指向必要的空间的地址。类似的,我们可以直接从覆盖中得到行尺寸信息。像前面一样我们使用img_convert来把格式转换成PIX_FMT_YUV420P。

绘制图像

但我们仍然需要告诉SDL如何来实际显示我们给的数据。我们也会传递一个表明电影位置、宽度、高度和缩放大小的矩形参数给SDL的函数。这样,SDL为我们做缩放并且它可以通过显卡的帮忙来进行快速缩放。

SDL_Rect rect;if(frameFinished) {/* ... code ... */// Convert the image into YUV format that SDL usessws_scale(sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, pCodecCtx->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(bmp);rect.x = 0;rect.y = 0;rect.w = pCodecCtx->width;rect.h = pCodecCtx->height;SDL_DisplayYUVOverlay(bmp, &rect);}

现在我们的视频显示出来了!

让我们再花一点时间来看一下SDL的特性:它的事件驱动系统。SDL被设置成当你在SDL中点击或者移动鼠标或者向它发送一个信号它都将产生一个事件的驱动方式。如果你的程序想要处理用户输入的话,它就会检测这些事件。你的程序也可以产生事件并且传递给SDL事件系统。当使用SDL进行多线程编程的时候,这相当有用,这方面代码我们可以在Tutorial 04中看到。在这个程序中,我们将在处理完包以后就立即轮询事件。现在而言,我们将处理SDL_QUIT事件以便于我们退出:

SDL_Event       event;av_free_packet(&packet);SDL_PollEvent(&event);switch(event.type) {case SDL_QUIT:SDL_Quit();exit(0);break;default:break;}

让我们去掉旧的冗余代码,开始编译。如果你使用的是Linux或者其变体,使用SDL库进行编译的最好方式为:

gcc -o tutorial02 tutorial02.c -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

这里的sdl-config命令会打印出用于gcc编译的包含正确SDL库的适当参数。为了进行编译,在你自己的平台你可能需要做的有点不同:请查阅一下 SDL文档中关于你的系统的那部分。一旦可以编译,就马上运行它。

当运行这个程序的时候会发生什么呢?电影简直跑疯了!实际上,我们只是以我们能从文件中解码帧的最快速度显示了所有的电影的帧。现在我们没有任何代码来计算出我们什么时候需要显示电影的帧。最后(在Tutorial 05),我们将花足够的时间来探讨同步问题。但一开始我们会先忽略这个,因为我们有更加重要的事情要处理:音频!

Tutorial 03: 播放音频

源代码:tutorial03.c

音频

现在我们要来播放声音。SDL也为我们准备了输出声音的方法。函数SDL_OpenAudio()本身就是用来打开声音设备的。它使用一个叫做 SDL_AudioSpec结构体作为参数,这个结构体中包含了我们将要输出的音频的所有信息。

在我们展示如何建立之前,让我们先解释一下电脑是如何处理音频的。数字音频是由一长串的样本流组成的。每个样本表示声音波形中的一个值。声音按照一个特定的采样率来进行录制,采样率表示以多快的速度来播放这段样本流,它的表示方式为每秒多少次采样。例如22050和44100的采样率就是电台和CD常用的采样率。此外,大多音频有不只一个通道来表示立体声或者环绕。例如,如果采样是立体声,那么每次的采样数就为2个。当我们从一个电影文件中等到数据的时候,我们不知道我们将得到多少个样本,但是FFmpeg将不会给我们部分的样本――这意味着它将不会把立体声分割开来。

SDL播放声音的流程是这样的:你先设置声音的选项——采样率(在SDL的结构体中被叫做freq [频率frequency] ),声音通道数和其它的参数,然后我们设置一个回调函数和一些用户数据userdata。当开始播放音频的时候,SDL将不断地调用这个回调函数并且要求它来向声音缓冲填入一个特定的数量的字节。当我们把这些信息放到SDL_AudioSpec结构体中后,我们调用函数SDL_OpenAudio()就会打开声音设备并且给我们返回另一个AudioSpec结构体。这个结构体是我们实际上用到的--因为我们不能保证得到我们所要求的。

设置音频

目前先把讲的记住,因为我们实际上还没有任何关于声音流的信息。让我们回过头去看一下前面Tutorial的代码,是如何找到视频流的,同样我们也可以找到声音流。

// Find the first video stream
videoStream=-1;
audioStream=-1;
for(i=0; i < pFormatCtx->nb_streams; i++) {if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_VIDEO&&videoStream < 0) {videoStream=i;}if(pFormatCtx->streams[i]->codec->codec_type==AVMEDIA_TYPE_AUDIO &&audioStream < 0) {audioStream=i;}
}
if(videoStream==-1)return -1; // Didn't find a video stream
if(audioStream==-1)return -1;

从这里我们可以从描述流的AVCodecContext中得到我们想要的信息,就像我们得到视频流的信息一样。

AVCodecContext *aCodecCtxOrig;
AVCodecContext *aCodecCtx;aCodecCtxOrig=pFormatCtx->streams[audioStream]->codec;

如果您还记得前面的Tutorial,我们仍然需要打开音频解码器。这很简单:

AVCodec         *aCodec;aCodec = avcodec_find_decoder(aCodecCtx->codec_id);
if(!aCodec) {fprintf(stderr, "Unsupported codec!\n");return -1;
}
// Copy context
aCodecCtx = avcodec_alloc_context3(aCodec);
if(avcodec_copy_context(aCodecCtx, aCodecCtxOrig) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context
}
/* set up SDL Audio here */avcodec_open2(aCodecCtx, aCodec, NULL);

从编解码上下文中我们能够得到用来设置音频的所有信息:

wanted_spec.freq = aCodecCtx->sample_rate;
wanted_spec.format = AUDIO_S16SYS;
wanted_spec.channels = aCodecCtx->channels;
wanted_spec.silence = 0;
wanted_spec.samples = SDL_AUDIO_BUFFER_SIZE;
wanted_spec.callback = audio_callback;
wanted_spec.userdata = aCodecCtx;if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());return -1;
}

让我们看看这些配置信息:

  • freq 前面所讲的采样率
  • format 告诉SDL我们将要给的格式。在“S16SYS”中的S表示有符号的signed,16表示每个样本是16位长的,SYS表示大小头的顺序是与使用的系统相同的。这些格式是由avcodec_decode_audio2为我们给出来的输入音频的格式。
  • channels 声音的通道数
  • silence 这是用来表示静音的值。因为声音采样是有符号的,所以当然就是0这个值。
  • samples 这是当我们想要更多声音的时候,我们想让SDL给出来的声音缓冲区的大小。一个比较合适的值在512到8192之间;ffplay使用1024。
  • callback 这个是我们的回调函数。我们后面将会详细讨论。
  • userdata 这个是SDL供给回调函数运行的参数。我们将让回调函数得到整个编解码的上下文;你将在后面知道原因。

最后,我们使用SDL_OpenAudio函数来打开音频。

队列

嗯!现在我们已经准备好从流中取出声音信息。但是我们如何来处理这些信息呢?我们将会不断地从文件中得到这些包,但同时SDL也将调用回调函数。解决方法是创建一个全局的结构体变量以便于我们从文件中得到的声音包有地方存放,同时也保证SDL中的声音回调函数audio_callback能从这个地方得到声音数据。所以我们要做的是创建一个包的队列queue。在FFmpeg中有一个叫AVPacketList的结构体可以帮助我们,这个结构体实际是一串包的链表。下面就是我们的队列结构体:

typedef struct PacketQueue {AVPacketList *first_pkt, *last_pkt;int nb_packets;int size;SDL_mutex *mutex;SDL_cond *cond;
} PacketQueue;

首先,我们应当指出nb_packets是与size不一样的-size表示我们从packet->size中得到的字节数。你会注意到我们有一个互斥量mutex和一个条件变量cond在结构体里面。这是因为SDL是在一个独立的线程中来进行音频处理的。如果我们没有正确的锁定这个队列,我们有可能把数据搞乱。我们将来看一个这个队列是如何来运行的。每一个程序员应当知道如何来生成的一个队列,然而我们还是把这部分加进来讨论,从而可以学习到SDL的函数。
一开始我们先创建一个函数来初始化队列:

void packet_queue_init(PacketQueue *q) {
memset(q, 0, sizeof(PacketQueue));
q->mutex = SDL_CreateMutex();
q->cond = SDL_CreateCond();
}

接着我们再做一个函数来给队列中填入东西:

int packet_queue_put(PacketQueue *q, AVPacket *pkt) {AVPacketList *pkt1;if(av_dup_packet(pkt) < 0) {return -1;}pkt1 = av_malloc(sizeof(AVPacketList));if (!pkt1)return -1;pkt1->pkt = *pkt;pkt1->next = NULL;SDL_LockMutex(q->mutex);if (!q->last_pkt)q->first_pkt = pkt1;elseq->last_pkt->next = pkt1;q->last_pkt = pkt1;q->nb_packets++;q->size += pkt1->pkt.size;SDL_CondSignal(q->cond);SDL_UnlockMutex(q->mutex);return 0;
}

函数SDL_LockMutex()锁定队列的互斥量以便于我们向队列中添加东西,然后函数SDL_CondSignal()通过我们的条件变量为一个接收函数(如果它在等待)发出一个信号来告诉它现在已经有数据了,接着就会解锁互斥量并让队列可以自由访问。

下面是相对应的取出函数。注意函数SDL_CondWait()是如何按照我们的要求让函数阻塞block的(例如一直等到队列中有数据)。

int quit = 0;static int packet_queue_get(PacketQueue *q, AVPacket *pkt, int block) {AVPacketList *pkt1;int ret;SDL_LockMutex(q->mutex);for(;;) {if(quit) {ret = -1;break;}pkt1 = q->first_pkt;if (pkt1) {q->first_pkt = pkt1->next;if (!q->first_pkt)q->last_pkt = NULL;q->nb_packets--;q->size -= pkt1->pkt.size;*pkt = pkt1->pkt;av_free(pkt1);ret = 1;break;} else if (!block) {ret = 0;break;} else {SDL_CondWait(q->cond, q->mutex);}}SDL_UnlockMutex(q->mutex);return ret;
}

正如你所看到的,这个函数使用无限循环以便于我们用阻塞的方式来得到数据。我们通过使用SDL中的函数SDL_CondWait()来避免无限循环。基本上,所有的CondWait只等待从SDL_CondSignal()函数(或者SDL_CondBroadcast()函数)中发出的信号,然后再继续执行。然而,虽然看起来我们陷入了我们的互斥体中-如果我们一直保持着这个锁,我们的函数将永远无法把数据放入到队列中去!但是,SDL_CondWait()函数也为我们做了解锁互斥量的动作然后才尝试着在得到信号后去重新锁定它。

意外情况

你们将会注意到我们有一个全局变量quit,我们用它来保证还没有设置程序退出的信号(SDL会自动处理TERM类似的信号)。否则,这个线程将不停地运行直到我们使用kill -9来结束程序。FFmpeg同样也提供了一个函数来进行回调并检查我们是否需要退出一些被阻塞的函数:这个函数就是 url_set_interrupt_cb。

  SDL_PollEvent(&event);switch(event.type) {case SDL_QUIT:quit = 1;

我们还需确保将标志位quit置为1。

将包加入队列

剩下的我们唯一需要做的就是配置队列了:

PacketQueue audioq;
main() {
...avcodec_open2(aCodecCtx, aCodec, NULL);packet_queue_init(&audioq);SDL_PauseAudio(0);

函数SDL_PauseAudio()最终让音频设备开始工作。如果没有立即供给足够的数据,它会播放静音。

我们已经建立好我们的队列,现在我们准备加入包了。先看一下我们如何循环读取包:

while(av_read_frame(pFormatCtx, &packet)>=0) {
// Is this a packet from the video stream?
if(packet.stream_index==videoStream) {// Decode video frame....}
} else if(packet.stream_index==audioStream) {packet_queue_put(&audioq, &packet);
} else {av_free_packet(&packet);
}

注意:我们没有在把包放到队列里的时候就释放它,而是将在我们解码之后再来释放它。

读取包

现在,我们最后让声音回调函数audio_callback从队列中取出包。回调函数的格式必需为void callback(void *userdata, Uint8 *stream, int len),这里的userdata就是我们传给 SDL的指针,stream是我们要把声音数据写入的缓冲区指针,len是缓冲区的大小。下面就是代码:

void audio_callback(void *userdata, Uint8 *stream, int len) {AVCodecContext *aCodecCtx = (AVCodecContext *)userdata;int len1, audio_size;static uint8_t audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2];static unsigned int audio_buf_size = 0;static unsigned int audio_buf_index = 0;while(len > 0) {if(audio_buf_index >= audio_buf_size) {/* We have already sent all our data; get more */audio_size = audio_decode_frame(aCodecCtx, audio_buf,sizeof(audio_buf));if(audio_size < 0) {/* If error, output silence */audio_buf_size = 1024;memset(audio_buf, 0, audio_buf_size);} else {audio_buf_size = audio_size;}audio_buf_index = 0;}len1 = audio_buf_size - audio_buf_index;if(len1 > len)len1 = len;memcpy(stream, (uint8_t *)audio_buf + audio_buf_index, len1);len -= len1;stream += len1;audio_buf_index += len1;}
}

这基本上是一个简单的从稍后我们将要实现的audio_decode_frame()解码函数中获取数据的循环,这个循环把结果写入到中间缓冲区,尝试着向流中写入len大小的字节数,并且在我们没有足够的数据的时候会获取更多的数据或者当我们有多余数据的时候保存下来为后面使用。这个audio_buf的大小为 1.5倍FFmpeg给出的声音最大帧的大小以便于有一个比较好的缓冲。

最后解码音频

让我们去实现解码器的真正函数,audio_decode_frame :

int audio_decode_frame(AVCodecContext *aCodecCtx, uint8_t *audio_buf,int buf_size) {static AVPacket pkt;static uint8_t *audio_pkt_data = NULL;static int audio_pkt_size = 0;static AVFrame frame;int len1, data_size = 0;for(;;) {while(audio_pkt_size > 0) {int got_frame = 0;len1 = avcodec_decode_audio4(aCodecCtx, &frame, &got_frame, &pkt);if(len1 < 0) {/* if error, skip frame */audio_pkt_size = 0;break;}audio_pkt_data += len1;audio_pkt_size -= len1;data_size = 0;if(got_frame) {data_size = av_samples_get_buffer_size(NULL, aCodecCtx->channels,frame.nb_samples,aCodecCtx->sample_fmt,1);assert(data_size <= buf_size);memcpy(audio_buf, frame.data[0], data_size);}if(data_size <= 0) {/* No data yet, get more frames */continue;}/* We have data, return it and come back for more later */return data_size;}if(pkt.data)av_free_packet(&pkt);if(quit) {return -1;}if(packet_queue_get(&audioq, &pkt, 1) < 0) {return -1;}audio_pkt_data = pkt.data;audio_pkt_size = pkt.size;}
}

整个过程实际上从函数的尾部开始,在这里我们调用了packet_queue_get()函数。我们从队列中取出包,并且保存它的信息。然后,一旦我们有了可以使用的包,我们就调用函数avcodec_decode_audio2(),它的功能就像它的姐妹函数 avcodec_decode_video()一样,唯一的区别是它的一个包里可能有不止一个声音帧,所以你可能要调用很多次来解码出包中所有的数据。一旦我们获取到帧,我们只需简单的将其复制到我们的音频缓冲区中,确保data_size小于我们的音频缓冲区。同时也要记住进行指针audio_buf的强制转换,因为SDL给出的是8位整型缓冲指针而FFmpeg给出的数据是16位的整型指针。你应该也会注意到 len1和data_size的不同,data_size表示实际返回的原始声音数据的大小。

当我们得到一些数据的时候,我们立刻返回来看一下是否仍然需要从队列中得到更加多的数据或者我们已经完成了。如果我们仍然有更加多的数据要处理,我们把它保存到下一次。如果我们完成了一个包的处理,我们最后要释放它。
就是这样。我们利用主读取循环将音频数据传送到队列中,然后被audio_callback回调函数从队列中读取并处理,最后把数据送给SDL,于是SDL就相当于我们的声卡。让我们继续并且编译:

gcc -o tutorial03 tutorial03.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

啊哈!视频虽然还是像原来那样快,但是声音可以正常播放了。这是为什么呢?因为声音信息中的采样率-虽然我们把声音数据尽可能快的填充到声卡缓冲中,但是声音设备却会按照原来指定的采样率来进行播放。

我们几乎已经准备好来开始同步音频和视频了,但是首先我们需要稍微组织一下我们的代码。在一个独立的线程中,用队列的方式来组织和播放音频会工作的更好:它使得程序更加易于控制和模块化。在我们开始同步音视频之前,我们需要让我们的代码更加容易处理。所以下次要讲的是:创建线程。

Tutorial 04:创建线程

源代码:tutorial04.c

概要

上次我们通过SDL提供的音频功能完成了音频播放。 SDL启动了一个线程以便每次需要音频数据时通过我们定义的回调函数获取。现在我们将对视频显示做同样的事情。这使代码更加模块化,更易于使用 - 特别是当我们要完成音视频同步功时。那么我们从哪里开始呢?

首先注意到我们的主功能函数处理了太多事情:它正在运行事件循环,读取数据包和解码音视频。所以我们要做的是将所有这些分开:我们将有一个负责解码数据包的线程;然后,这些数据包将被添加到对应的音视频队列中,并由相应的音频和视频线程读取。音频线程我们已经按照我们想要的方式设置了它;由于我们必须自己显示视频,视频线程会有点复杂。我们将实际的显示代码添加到主循环中。但是,我们不是在每次循环时就显示视频,而是将视频显示同步到事件循环中。我们的想法是解码视频,将结果帧保存在另一个队列中,然后我们创建自定义事件(FF_REFRESH_EVENT),并添加到事件系统中,最后当我们的事件循环看到此事件时,它将显示队列中的下一帧。这是一个手写式流程插图:

________ audio  _______      _____
|        | pkts |       |    |     | to spkr
| DECODE |----->| AUDIO |--->| SDL |-->
|________|      |_______|    |_____||  video     _______|   pkts    |       |+---------->| VIDEO |________       |_______|   _______
|       |          |       |       |
| EVENT |          +------>| VIDEO | to mon.
| LOOP  |----------------->| DISP. |-->
|_______|<---FF_REFRESH----|_______|

通过事件循环来动态控制视频显示的主要目的是采用SDL_Delay线程,这样我们就可以精确的控制下一个视频帧何时出现在屏幕上。 当我们最终在下一个Tutorial中完成音视频同步时,添加何时去刷新下一帧,以便在屏幕上正确的时间去显示正确的图片的控制代码,将不在是一件很麻烦的事情。

简化代码

我们还要稍微清理一下代码。 音频和视频编解码器信息我们已经全部获得,我们还将创建队列和缓冲区,谁知道还有什么。 所有这些东西都用于一个逻辑单元,即电影。 因此,我们将创建一个包含所有信息的大型结构体 — VideoState

typedef struct VideoState {AVFormatContext *pFormatCtx;int             videoStream, audioStream;AVStream        *audio_st;AVCodecContext  *audio_ctx;PacketQueue     audioq;uint8_t         audio_buf[(AVCODEC_MAX_AUDIO_FRAME_SIZE * 3) / 2];unsigned int    audio_buf_size;unsigned int    audio_buf_index;AVPacket        audio_pkt;uint8_t         *audio_pkt_data;int             audio_pkt_size;AVStream        *video_st;AVCodecContext  *video_ctx;PacketQueue     videoq;VideoPicture    pictq[VIDEO_PICTURE_QUEUE_SIZE];int             pictq_size, pictq_rindex, pictq_windex;SDL_mutex       *pictq_mutex;SDL_cond        *pictq_cond;SDL_Thread      *parse_tid;SDL_Thread      *video_tid;char            filename[1024];int             quit;
} VideoState;

从这个结构体定义中我们可以看到我们将要达到的目标。首先,我们看到基本信息 - 格式上下文和音频、视频流索引,以及相应的AVStream对象。 然后我们可以看到已经将部分音频缓冲区移动到这个结构中。 这些(audio_buf,audio_buf_size等)都是关于已经存在的音频(或缺乏音频)数据的信息。 我们为视频添加了另一个队列,并为解码的帧(保存为Overlay)添加了一个缓冲区(将被用作队列;但我们不需要任何的排列功能)。 VideoPicture结构是我们自己的定义的(当我们使用它时,我们会看到它里面有什么)。 我们还注意到我们已经为我们将要创建的两个额外线程分配了指针,以及退出标志和电影的文件名。

现在我们主函数中使用它,看看它如何改变我们的程序。让我们设置VideoState结构:

int main(int argc, char *argv[]) {SDL_Event       event;VideoState      *is;is = av_mallocz(sizeof(VideoState));

av_mallocz()是一个很好的函数,它将为我们分配内存并将其归零。

然后我们初始化对显示缓冲区(pictq)的锁定,因为事件循环会调用我们的显示函数,记住,将从pictq中提取预解码的帧。 与此同时,我们的视频解码器将把信息放入其中 - 我们不知道谁将首先被调用。 希望你认识到这是一个典型的竞争条件。 所以我们在启动任何线程之前就分配它。 我们还将我们电影的文件名复制到VideoState中。

av_strlcpy(is->filename, argv[1], sizeof(is->filename));is->pictq_mutex = SDL_CreateMutex();
is->pictq_cond = SDL_CreateCond();

av_strlcpy是FFmpeg定义的函数,它在strncpy函数之外执行一些额外的边界检查。

我们的第一个线程

现在让我们启动线程,让它完成工作:

schedule_refresh(is, 40);is->parse_tid = SDL_CreateThread(decode_thread, is);
if(!is->parse_tid) {av_free(is);return -1;
}

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;AVCodec *codec;SDL_AudioSpec wanted_spec, spec;if(stream_index < 0 || stream_index >= pFormatCtx->nb_streams) {return -1;}codec = avcodec_find_decoder(pFormatCtx->streams[stream_index]->codec->codec_id);if(!codec) {fprintf(stderr, "Unsupported codec!\n");return -1;}codecCtx = avcodec_alloc_context3(codec);if(avcodec_copy_context(codecCtx, pFormatCtx->streams[stream_index]->codec) != 0) {fprintf(stderr, "Couldn't copy codec context");return -1; // Error copying codec context}if(codecCtx->codec_type == AVMEDIA_TYPE_AUDIO) {// Set audio settings from codec infowanted_spec.freq = codecCtx->sample_rate;/* ...etc... */wanted_spec.callback = audio_callback;wanted_spec.userdata = is;if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {fprintf(stderr, "SDL_OpenAudio: %s\n", SDL_GetError());return -1;}}if(avcodec_open2(codecCtx, codec, NULL) < 0) {fprintf(stderr, "Unsupported codec!\n");return -1;}switch(codecCtx->codec_type) {case AVMEDIA_TYPE_AUDIO:is->audioStream = stream_index;is->audio_st = pFormatCtx->streams[stream_index];is->audio_ctx = codecCtx;is->audio_buf_size = 0;is->audio_buf_index = 0;memset(&is->audio_pkt, 0, sizeof(is->audio_pkt));packet_queue_init(&is->audioq);SDL_PauseAudio(0);break;case AVMEDIA_TYPE_VIDEO:is->videoStream = stream_index;is->video_st = pFormatCtx->streams[stream_index];is->video_ctx = codecCtx;packet_queue_init(&is->videoq);is->video_tid = SDL_CreateThread(video_thread, is);is->sws_ctx = sws_getContext(is->video_st->codec->width, is->video_st->codec->height,is->video_st->codec->pix_fmt, is->video_st->codec->width,is->video_st->codec->height, PIX_FMT_YUV420P,SWS_BILINEAR, NULL, NULL, NULL);break;default:break;}
}

这与我们之前的代码几乎相同,只是现在它被泛化同时用于音频和视频。 请注意,传递给音频回调函数的用户数据是大结构体,而不是AVCodecContext。 我们还将流本身保存为audio_st和video_st。 我们还添加了视频队列,并与设置音频队列相同的方式进行设置。 最重要的是启动视频和音频线程。

  SDL_PauseAudio(0);break;/* ...... */is->video_tid = SDL_CreateThread(video_thread, is);

我们记得上次使用过的SDL_PauseAudio(),并且SDL_CreateThread()的使用方式与之前完全相同。 我们将回到video_thread()函数。

在此之前,让我们回到decode_thread()函数的后半部分。 它基本上只是一个for循环,它将读入数据包并将其放在正确的队列中:

 for(;;) {if(is->quit) {break;}// seek stuff goes hereif(is->audioq.size > MAX_AUDIOQ_SIZE ||is->videoq.size > MAX_VIDEOQ_SIZE) {SDL_Delay(10);continue;}if(av_read_frame(is->pFormatCtx, packet) < 0) {if((is->pFormatCtx->pb->error) == 0) {SDL_Delay(100); /* no error; wait for user input */continue;} else {break;}}// Is this a packet from the video stream?if(packet->stream_index == is->videoStream) {packet_queue_put(&is->videoq, packet);} else if(packet->stream_index == is->audioStream) {packet_queue_put(&is->audioq, packet);} else {av_free_packet(packet);}}

这里没有什么新东西,除了我们给音频和视频队列限定了一个最大值,另外我们添加一个检测读错误的函数。格式上下文里面有一个叫做pb的ByteIOContext类型结构体。这个结构体是用来保存一些低级的文件信息。

在循环以后,我们的代码在等待其余的程序结束和告知程序已经结束。这些代码是有指引性,因为它指示出了如何推送事件--后面我们将显示图像。

  while(!is->quit) {SDL_Delay(100);}fail:if(1){SDL_Event event;event.type = FF_QUIT_EVENT;event.user.data1 = is;SDL_PushEvent(&event);}return 0;

我们使用SDL常量SDL_USEREVENT来从用户事件分配值。第一个用户事件的值应当是SDL_USEREVENT,下一个是 SDL_USEREVENT+1并且依此类推。在我们的程序中FF_QUIT_EVENT被定义成SDL_USEREVENT+2。如果喜欢,我们也可以传递用户数据,在这里我们传递的是大结构体的指针。最后我们调用SDL_PushEvent()函数。在我们的事件切换中,我们只是像以前推送SDL_QUIT_EVENT事件那部分一样推送FF_QUIT_EVENT事件。我们将在自己的事件队列中详细讨论,现在只是确保我们正确放入了FF_QUIT_EVENT事件,我们将在后面捕捉到它并且设置我们的退出标志quit。

得到帧:video_thread

当我们准备好解码器后,我们启动视频线程。这个线程从视频队列中读取包,把它解码成视频帧,然后调用queue_picture函数把处理好的帧放入到图片队列中:

int video_thread(void *arg) {
VideoState *is = (VideoState *)arg;
AVPacket pkt1, *packet = &pkt1;
int frameFinished;
AVFrame *pFrame;pFrame = av_frame_alloc();for(;;) {if(packet_queue_get(&is->videoq, packet, 1) < 0) {// means we quit getting packetsbreak;}// Decode video frameavcodec_decode_video2(is->video_st->codec, pFrame, &frameFinished, packet);// Did we get a video frame?if(frameFinished) {if(queue_picture(is, pFrame) < 0) {break;}}av_free_packet(packet);
}
av_free(pFrame);
return 0;
}

在这里的很多函数应该都很熟悉吧。我们把avcodec_decode_video函数移到了这里,替换了一些参数,例如:我们把AVStream保存在我们自己的大结构体中,所以我们可以从那里得到编解码器的信息。我们仅仅是不断的从视频队列中取包一直到有人告诉我们要停止或者出错为止。

把帧队列化

让我们看一下将解码后的帧pFrame保存到图像队列中的函数。因为我们的图像队列是SDL的覆盖集合(基本上不用让视频显示函数再做计算了),我们需要把帧转换成相应的格式。我们保存到图像队列中的数据是我们自己做的一个结构体。

typedef struct VideoPicture {SDL_Overlay *bmp;int width, height; /* source height & width */int allocated;
} VideoPicture;

我们的大结构体有一个可以保存这些的缓冲区。然而,我们需要自己来申请SDL_Overlay(注意:allocated标志会指明我们是否已经做了这个申请动作)。

为了使用这个队列,我们有两个指针-写入指针和读取指针。 我们还会跟踪缓冲区中图片的数量。要写入到队列中,我们先要等待缓冲清空以便于有位置来保存我们的VideoPicture。然后我们检查看是否已经申请到了一个可以写入覆盖的索引号。如果没有,我们将不得不分配一些空间。如果窗口大小发生变化,我们还必须重新分配缓冲区!

int queue_picture(VideoState *is, AVFrame *pFrame) {VideoPicture *vp;int dst_pix_fmt;AVPicture pict;/* wait until we have space for a new pic */SDL_LockMutex(is->pictq_mutex);while(is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE &&!is->quit) {SDL_CondWait(is->pictq_cond, is->pictq_mutex);}SDL_UnlockMutex(is->pictq_mutex);if(is->quit)return -1;// windex is set to 0 initiallyvp = &is->pictq[is->pictq_windex];/* allocate or resize the buffer! */if(!vp->bmp ||vp->width != is->video_st->codec->width ||vp->height != is->video_st->codec->height) {SDL_Event event;vp->allocated = 0;alloc_picture(is);if(is->quit) {return -1;}}

让我们看看alloc_picture()函数:

void alloc_picture(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;vp = &is->pictq[is->pictq_windex];if(vp->bmp) {// we already have one make another, bigger/smallerSDL_FreeYUVOverlay(vp->bmp);}// Allocate a place to put our YUV image on that screenSDL_LockMutex(screen_mutex);vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width,is->video_st->codec->height,SDL_YV12_OVERLAY,screen);SDL_UnlockMutex(screen_mutex);vp->width = is->video_st->codec->width;vp->height = is->video_st->codec->height;  vp->allocated = 1;
}

您应该已经看到我们将SDL_CreateYUVOverlay函数从主循环移动到这里。 此代码现在应该是能够自我注释说明的。 但是,现在我们有一个互斥锁,因为两个线程无法同时向屏幕写入信息! 这将防止alloc_picture函数和图片显示函数相互竞争。 (我们已将此锁创建为全局变量并在main()中初始化;请参阅代码。)请记住,我们在VideoPicture结构体中保存了宽度和高度,因为某些原因我们需要确保我们的视频大小不会更改。

OK,我们都已准备好了,并且也分配了YUV Overlay以便接收图像。让我们回顾一下queue_picture函数,并看一下拷贝帧到Overlay的代码。你应该能认出其中的一部分:

int queue_picture(VideoState *is, AVFrame *pFrame) {/* Allocate a frame if we need it... *//* ... *//* We have a place to put our picture on the queue */if(vp->bmp) {SDL_LockYUVOverlay(vp->bmp);dst_pix_fmt = PIX_FMT_YUV420P;/* point pict at the queue */pict.data[0] = vp->bmp->pixels[0];pict.data[1] = vp->bmp->pixels[2];pict.data[2] = vp->bmp->pixels[1];pict.linesize[0] = vp->bmp->pitches[0];pict.linesize[1] = vp->bmp->pitches[2];pict.linesize[2] = vp->bmp->pitches[1];// Convert the image into YUV format that SDL usessws_scale(is->sws_ctx, (uint8_t const * const *)pFrame->data,pFrame->linesize, 0, is->video_st->codec->height,pict.data, pict.linesize);SDL_UnlockYUVOverlay(vp->bmp);/* now we inform our display thread that we have a pic ready */if(++is->pictq_windex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_windex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size++;SDL_UnlockMutex(is->pictq_mutex);}return 0;
}

大部分内容只是我们之前用来填充YUV Overlay的代码。 最后一点只是简单地将我们的值“添加”到队列中。 队列的工作方式是在其未满时可以一直往里添加东西,只要队列有值,我们就可读取它。 因此,一切都取决于is-> pictq_size值,要求我们锁定它。 所以我们在这里增加写指针(在必要的时候采用轮转的方式),然后锁定队列并增加其大小。 现在读取者可以了解到有关队列的更多信息,并且如果队列满了,写入者也将会知道。

显示视频

开始轮到我们的视频线程了。现在我们看过了几乎所有的线程,除了一个 - 记得我们调用了schedule_refresh()函数吗?让我们看一下实际它是如何工作的:

/* schedule a video refresh in 'delay' ms */
static void schedule_refresh(VideoState *is, int delay) {SDL_AddTimer(delay, sdl_refresh_timer_cb, is);
}

SDL中的函数SDL_AddTimer()会定时(计时单位毫秒)去执行用户定义的回调函数(可以选择性传入用户参数)。我们将用这个函数来定时刷新视频 - 每次我们调用这个函数的时候,它将设置一个定时器来触发事件,在主函数中轮流把一帧从图像队列中显示到屏幕上。 但是,首先让我们先触发那个事件。

static Uint32 sdl_refresh_timer_cb(Uint32 interval, void *opaque) {SDL_Event event;event.type = FF_REFRESH_EVENT;event.user.data1 = opaque;SDL_PushEvent(&event);return 0; /* 0 means stop timer */
}

这里推送了一个现在很熟悉的事件 - FF_REFRESH_EVENT,被定义成SDL_USEREVENT+1。要注意的一件事是当返回0的时候,SDL关闭定时器,回调就不会再被调用。

现在我们产生了一个FF_REFRESH_EVENT事件,我们需要在事件循环中处理它:

for(;;) {SDL_WaitEvent(&event);switch(event.type) {/* ... */case FF_REFRESH_EVENT:video_refresh_timer(event.user.data1);break;

于是我们就进入到以下函数,在这个函数中会把数据从图像队列中取出:

void video_refresh_timer(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];/* Timing code goes here */schedule_refresh(is, 80);/* show the picture! */video_display(is);/* update queue for next picture! */if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_rindex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size--;SDL_CondSignal(is->pictq_cond);SDL_UnlockMutex(is->pictq_mutex);}} else {schedule_refresh(is, 100);}
}

现在,这只是一个极其简单的函数:当队列中有数据的时候,就从其中取出,为下一帧设置定时器,调用video_display函数来真正显示图像到屏幕上,然后把队列读索引值加1,并且把队列的尺寸size减1。你可能会注意到在这个函数中我们并没有真正对vp做一些实际的动作,原因是这样的:我们将在后面处理。我们将在后面同步音频和视频的时候用它来访问时间信息。你会在这里看到这个注释信息“校时代码”。那里我们将讨论显示下一帧视频的时间间隔,然后把相应的值写入到schedule_refresh()函数中。现在我们只是随便写入一个值80。从技术上来讲,你可以猜测并验证这个值,并且为每个电影重新编译程序,但是:1)过一段时间它会漂移;2)这种方式是很笨的。我们将在后面来讨论它。

我们几乎做完了;我们仅仅剩下最后一件事:显示视频!下面就是video_display函数:

void video_display(VideoState *is) {SDL_Rect rect;VideoPicture *vp;float aspect_ratio;int w, h, x, y;int i;vp = &is->pictq[is->pictq_rindex];if(vp->bmp) {if(is->video_st->codec->sample_aspect_ratio.num == 0) {aspect_ratio = 0;} else {aspect_ratio = av_q2d(is->video_st->codec->sample_aspect_ratio) *is->video_st->codec->width / is->video_st->codec->height;}if(aspect_ratio <= 0.0) {aspect_ratio = (float)is->video_st->codec->width /(float)is->video_st->codec->height;}h = screen->h;w = ((int)rint(h * aspect_ratio)) & -3;if(w > screen->w) {w = screen->w;h = ((int)rint(w / aspect_ratio)) & -3;}x = (screen->w - w) / 2;y = (screen->h - h) / 2;rect.x = x;rect.y = y;rect.w = w;rect.h = h;SDL_LockMutex(screen_mutex);SDL_DisplayYUVOverlay(vp->bmp, &rect);SDL_UnlockMutex(screen_mutex);}
}

因为我们的屏幕可以是任意尺寸(我们设置为640x480并且用户可以自己来改变尺寸),我们需要动态计算出我们显示的图像的矩形大小。所以一开始我们需要计算出电影的纵横比aspect ratio,即宽度/高度。某些编解码器会有奇数采样纵横比sample aspect ratio,只是简单表示了一个像素或者一个采样的宽度/高度的比例。因为宽度和高度在我们的编解码器中是用像素为单位的,所以实际的纵横比与纵横比乘以样本纵横比相同。某些编解码器会显示纵横比为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。
这就是了!让我们编译它:

gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

请享受一下没有经过同步的电影!下次我们将编译一个可以最终工作的电影播放器。

Tutorial: 05 音视频同步

源代码:tutorial05.c

警告

当我第一次完成本Tutorial时,所有音视频同步代码都是从ffplay.c中提取的。 今天,ffplay是一个完全不同的程序,ffmpeg库(以及ffplay.c本身)的改进已经导致了一些策略的改变。 虽然本Tutorial示例代码仍然有效,但它看起来不太好,本Tutorial也还有许多可以改进的地方。

音视频如何同步

在前面一段时间里,我们完成了一个几乎无用的视频播放器。当然,它能播放视频,也能播放音频,但是它还不能被称为一部电影。那么我们还要做什么呢?

PTS 和 DTS

幸运的是,音频和视频流里都有关于以多快速度和什么时间来播放它们的信息。音频流有采样率,视频流有帧率。然而,如果我们只是简单的通过数帧和乘以帧率的方式来同步视频,那么就很有可能会失去音频同步。于是作为一种补充,在流中的包有种叫做 DTS(decoding time stamp)PTS(a presentation time stamp) 的机制。为了弄明白这两个参数,你需要了解电影的存储方式。像MPEG等格式,使用被叫做B帧(B表示双向bidrectional)的方式。另外两种帧被叫做I帧和P帧(I表示关键帧,P表示预测帧)。I帧包含了某个特定的完整图像。P帧依赖于前面的I帧和P帧并且使用比较或者差分的方式来编码。B帧与P帧有点类似,但是它是依赖于前面和后面帧的信息。这也就解释了为什么我们可能在调用avcodec_decode_video以后会得不到一帧图像。

所以对于一个电影,帧是这样来显示的:I B B P。现在我们需要在显示B帧之前知道P帧中的信息。因此,帧可能会按照这样的方式来存储:IPBB。这就是为什么我们会有一个解码时间戳和一个显示时间戳的原因。解码时间戳告诉我们什么时候需要解码,显示时间戳告诉我们什么时候需要显示。所以,在这种情况下,我们的流可以是这样的:

PTS: 1 4 2 3

DTS: 1 2 3 4

Stream: I P B B

通常PTS和DTS只有在流中有B帧的时候才会不同。

当我们调用av_read_frame()得到一个包的时候,PTS和DTS的信息也会保存在包中。但是我们真正想要的PTS是我们刚刚解码出来的原始帧的PTS,这样我们才能知道什么时候来显示它。

幸运的是,FFmpeg为我们提供了"best effort"时间戳,您可以通过调用dav_frame_get_best_effort_timestamp()获取。

同步

现在,知道了什么时候来显示一个视频帧真好,但是我们怎样来实际操作呢?这里有个idea:当我们显示了一帧以后,我们计算出下一帧显示的时间。然后简单的设置一个新的定时器来刷新。你可能会想,我们检查下一帧的PTS值,然后对比系统时钟来得到需要多久来显示下一帧。这种方式可以工作,但是有两种情况要处理。

首先,要知道下一个PTS是多少。现在我们将视频帧率和PTS相结合-太对了!然而,有些电影需要帧重复。这意味着我们重复播放当前的帧。这将导致程序显示下一帧太快了。所以我们需要计算它们。 第二,正如现在的程序,视频和音频播放的很快,一点也不同步。如果一切都工作得很好的话,我们不必担心。但是,你的电脑并不是最好的,很多视频文件也不是完好的。所以,我们有三种选择:同步音频到视频,同步视频到音频,或者都同步到外部时钟(例如你的电脑时钟)。从现在开始,我们将同步视频到音频。

编码:获得帧的时间戳

现在让我们到代码中来做这些事情。我们将需要为我们的大结构体添加一些成员,但是我们会根据需要来添加。首先,让我们看一下视频线程。记住,在这里我们得到了解码线程输出到队列中的包。这部分代码我们需要做的是通过avcodec_decode_video2函数得到帧的时间戳。我们讨论的第一种方式是从上次处理的包中得到DTS,这是很容易的:

double pts;for(;;) {if(packet_queue_get(&is->videoq, packet, 1) < 0) {// means we quit getting packetsbreak;}pts = 0;// Decode video framelen1 = avcodec_decode_video2(is->video_st->codec,pFrame, &frameFinished, packet);if(packet->dts != AV_NOPTS_VALUE) {pts = av_frame_get_best_effort_timestamp(pFrame);} else {pts = 0;}pts *= av_q2d(is->video_st->time_base);

如果我们得不到PTS就把它设置为0。

好吧,这很容易。一个技术方面的注释:您可能已经注意到我们正在使用int64存储PTS。 这是因为PTS存储为整数。 此值是一个时间戳,对应于通过流中time_base单元来度量得到帧显示时间。例如,如果一个流每秒有24帧,那么PTS为42将指示如果每隔1/24秒我们有一个帧,那么帧应该到第42帧的位置(当然不一定是真的)。

我们可以通过除以帧率将此值转换为秒。 流的time_base值将为1/帧率(对于固定帧率而言),因此为了以秒为单位表示PTS,我们乘以time_base。

编码:使用PTS来同步

现在我们得到了PTS。我们要注意前面讨论到的两个同步问题。我们将定义一个函数叫做synchronize_video,它可以更新PTS以便能够正确同步。这个函数也能处理我们得不到PTS的情况。同时我们要知道下一帧的显示时间以便于正确设置刷新速率。于是我们需要一个内部变量video_clock来记录视频播放时长。我们把这些变量添加到大结构体中。

typedef struct VideoState {double          video_clock; // pts of last decoded frame / predicted pts of next decoded frame

下面是函数synchronize_video,它可以很好的自我解释:

double synchronize_video(VideoState *is, AVFrame *src_frame, double pts) {double frame_delay;if(pts != 0) {/* if we have pts, set video clock to it */is->video_clock = pts;} else {/* if we aren't given a pts, set it to the clock */pts = is->video_clock;}/* update the video clock */frame_delay = av_q2d(is->video_st->codec->time_base);/* if we are repeating a frame, adjust clock accordingly */frame_delay += src_frame->repeat_pict * (frame_delay * 0.5);is->video_clock += frame_delay;return pts;
}

你也会注意到我们同时计算了重复的帧。

现在让我们得到正确的PTS并且使用queue_picture来队列化帧,添加一个新的时间戳参数pts:

    // Did we get a video frame?if(frameFinished) {pts = synchronize_video(is, pFrame, pts);if(queue_picture(is, pFrame, pts) < 0) {break;}}

对于queue_picture来说唯一改变的事情就是我们把时间戳值pts保存到VideoPicture结构体中,我们必需添加一个时间戳变量到结构体中,只需添加一行代码:

typedef struct VideoPicture {...double pts;
}
int queue_picture(VideoState *is, AVFrame *pFrame, double pts) {... stuff ...if(vp->bmp) {... convert picture ...vp->pts = pts;... alert queue ...}

现在我们的图像队列中的所有图像都有了正确的时间戳值,所以让我们看一下视频刷新函数。你会记得上次我们用80ms的刷新时间来欺骗它。那么,现在我们将会算出实际的值。

我们的策略是通过简单计算前一帧和现在这一帧的时间戳来预测出下一个时间戳的时间。同时,我们需要同步视频到音频。我们将设置一个变量 audio clock 来记录音频正在播放的位置。这就像任何MP3播放器上的数字读数。既然我们把视频同步到音频,视频线程使用这个值来算出是否太快还是太慢。

我们将在后面来实现这些代码;现在我们假设我们已经有一个可以给我们音频时间的函数get_audio_clock。一旦我们有了这个值,那么我们在音频和视频失去同步的时候应该做些什么呢?简单而有点笨的办法是试着用seek跳到正确帧,或者其它的方式来解决。不同的是,我们会调整下次刷新的值:如果PTS远远落后于音频时间,我们将计算出的延迟加倍, 如果PTS远远超过音频时间,我们需要尽快刷新。既然我们有了调整过的时间和延迟,我们将把它和我们通过 frame_timer计算出来的时间进行比较。这个frame_timer将会统计出电影播放中所有的延时。换句话说,这个 frame_timer就是指我们什么时候来显示下一帧。我们简单的在frame_time加上新的延时,把它和电脑的系统时间进行比较,然后使用这个值来做下一次刷新。这可能有点难以理解,所以请认真研究代码:

void video_refresh_timer(void *userdata) {VideoState *is = (VideoState *)userdata;VideoPicture *vp;double actual_delay, delay, sync_threshold, ref_clock, diff;if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];delay = vp->pts - is->frame_last_pts; /* the pts from last time */if(delay <= 0 || delay >= 1.0) {/* if incorrect delay, use previous one */delay = is->frame_last_delay;}/* save for next time */is->frame_last_delay = delay;is->frame_last_pts = vp->pts;/* update delay to sync to audio */ref_clock = get_audio_clock(is);diff = vp->pts - ref_clock;/* Skip or repeat the frame. Take delay into accountFFPlay still doesn't "know if this is the best guess." */sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay : AV_SYNC_THRESHOLD;if(fabs(diff) < AV_NOSYNC_THRESHOLD) {if(diff <= -sync_threshold) {delay = 0;} else if(diff >= sync_threshold) {delay = 2 * delay;}}is->frame_timer += delay;/* computer the REAL delay */actual_delay = is->frame_timer - (av_gettime() / 1000000.0);if(actual_delay < 0.010) {/* Really it should skip the picture instead */actual_delay = 0.010;}schedule_refresh(is, (int)(actual_delay * 1000 + 0.5));/* show the picture! */video_display(is);/* update queue for next picture! */if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {is->pictq_rindex = 0;}SDL_LockMutex(is->pictq_mutex);is->pictq_size--;SDL_CondSignal(is->pictq_cond);SDL_UnlockMutex(is->pictq_mutex);}} else {schedule_refresh(is, 100);}
}

我们在这里做了很多检查:首先,我们保证现在的时间戳和上一个时间戳之间的delay是有效的。如果无效的话,我们只能用猜测方式用上次的延迟。接着,我们有一个同步阈值,因为在同步的时候事情并不总是那么完美的。在ffplay中使用0.01作为它的值。我们也保证阈值不会比时间戳之间的间隔短。最后,我们把最小的刷新值设置为10毫秒。

我们给大结构体添加了很多的变量,所以不要忘记检查一下代码。同时也不要忘记在函数streame_component_open中初始化帧时间 frame_timer和前面的帧延迟frame delay:

    is->frame_timer = (double)av_gettime() / 1000000.0;is->frame_last_delay = 40e-3;

同步:音频时钟

现在让我们看一下怎样来得到音频时钟。我们可以在音频解码函数audio_decode_frame中更新时钟时间。现在,请记住我们并不是每次调用这个函数的时候都在处理新的包,所以有两个地方需要更新时钟。第一个地方是我们得到新的包的时候:我们简单的设置声音时钟为这个包的时间戳。然后,如果一个包里有许多帧,我们通过统计样本数并乘以采样率来计算音频时钟,所以当我们得到包的时候:

 /* if update, update the audio clock w/pts */if(pkt->pts != AV_NOPTS_VALUE) {is->audio_clock = av_q2d(is->audio_st->time_base)*pkt->pts;}

然后当我们处理这个包的时候:

  /* Keep audio_clock up-to-date */pts = is->audio_clock;*pts_ptr = pts;n = 2 * is->audio_st->codec->channels;is->audio_clock += (double)data_size /(double)(n * is->audio_st->codec->sample_rate);

一点细节:模板函数被改成包含pts_ptr,所以要保证你同步做了修改。pts_ptr是一个用来通知audio_callback函数当前声音包的时间戳的指针。这将在下次用来同步声音和视频。
现在我们可以最后来实现我们的get_audio_clock函数。它并不像得到is->audio_clock值那样简单。注意我们会在每次调用它的时候设置音频时间戳,但是如果你看了audio_callback函数,它需要时间来把数据从音频包中移动到我们的输出缓冲区中。这意味着我们音频时钟中记录的时间比实际的要早太多。所以我们必须要检查一下我们还有多少没有写入。下面是完整的代码:

double get_audio_clock(VideoState *is) {double pts;int hw_buf_size, bytes_per_sec, n;pts = is->audio_clock; /* maintained in the audio thread */hw_buf_size = is->audio_buf_size - is->audio_buf_index;bytes_per_sec = 0;n = is->audio_st->codec->channels * 2;if(is->audio_st) {bytes_per_sec = is->audio_st->codec->sample_rate * n;}if(bytes_per_sec) {pts -= (double)hw_buf_size / bytes_per_sec;}return pts;
}

现在你应该知道为什么这个函数可以正常工作了。

让我们编译它:

gcc -o tutorial05 tutorial05.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

最后,你可以使用我们自己的电影播放器来看电影了。下次我们将看一下如何音频同步,然后接下来的Tutorial我们会讨论seeking。

Tutorial 06: 音频同步

源代码:tutorial06.c

概要

现在我们已经有了一个比较像样的播放器。所以让我们看看还有哪些零碎的东西没处理。前面我们只是简单提了一下音频同步到视频时钟的音视频同步方式,我们将采用和视频同步到音频时钟一样的做法:设置一个内部视频时钟来记录视频播放时长,然后音频据此进行同步。后面我们也来看一下如何推而广之把音频和视频都同步到外部时钟。

生成视频时钟

现在我们要生成一个类似于上篇Tutorial音频时钟的视频时钟:一个保存当前视频播放时间的内部值。开始,你可能会想这和使用上一帧的时间戳来更新定时器一样简单。但是,不要忘了视频帧之间的时间间隔是很长的,间隔时长是以毫秒为计量的。解决办法是保存另外一个时间值:我们设置上一帧PTS的视频时钟的时间。于是当前视频时钟的值就是PTS_of_last_frame + (current_time - time_elapsed_since_PTS_value_was_set)。这种解决方式与我们在函数get_audio_clock中的做法很类似。所在在我们的大结构体中,我们将添加一个double 变量video_current_pts和一个int64_t变量 video_current_pts_time。时钟更新将被放在video_refresh_timer函数中。

void video_refresh_timer(void *userdata) {/* ... */if(is->video_st) {if(is->pictq_size == 0) {schedule_refresh(is, 1);} else {vp = &is->pictq[is->pictq_rindex];is->video_current_pts = vp->pts;is->video_current_pts_time = av_gettime();

不要忘了在stream_component_open函数中进行初始化。

    is->video_current_pts_time = av_gettime();

现在我们需要一个方法去获取视频时钟:

double get_video_clock(VideoState *is) {double delta;delta = (av_gettime() - is->video_current_pts_time) / 1000000.0;return is->video_current_pts + delta;
}

时钟抽象化

但是为什么要强制使用视频时钟呢?我们更改视频同步代码以致于音频和视频不会试着去相互同步。想像一下我们让它像ffplay一样有一个命令行参数。所以让我们抽象一下:我们将创建一个新的封装函数,get_master_clock来检查av_sync_type变量,然后调用get_audio_clock,get_video_clock或我们想要使用的任何其他时钟。 我们甚至可以使用系统时钟,抽象为get_external_clock:

enum {AV_SYNC_AUDIO_MASTER,AV_SYNC_VIDEO_MASTER,AV_SYNC_EXTERNAL_MASTER,
};#define DEFAULT_AV_SYNC_TYPE AV_SYNC_VIDEO_MASTERdouble get_master_clock(VideoState *is) {if(is->av_sync_type == AV_SYNC_VIDEO_MASTER) {return get_video_clock(is);} else if(is->av_sync_type == AV_SYNC_AUDIO_MASTER) {return get_audio_clock(is);} else {return get_external_clock(is);}
}
main() {
...is->av_sync_type = DEFAULT_AV_SYNC_TYPE;
...
}

同步音频

现在是最难的部分:同步音频到视频时钟。我们的策略是计算音频播放位置,把它与视频时间比较然后算出我们需要修正多少样本数,也就是说:我们是否需要通过丢弃样本的方式来加速播放还是需要通过插值样本的方式来延缓播放?

我们将在每次处理声音样本的时候运行一个synchronize_audio的函数来正确的丢弃或者插值声音样本。然而,我们不想在每次发现有偏差的时候都进行同步,因为这样会使调整音频多于视频包。所以我们为函数synchronize_audio设置一个最小连续值来限定需要同步的时刻,这样我们就不会总是在调整了。当然,就像上次那样,“失去同步”意味着声音时钟和视频时钟的差异大于我们的阈值。

所以我们将使用一个分数系数,比如c,所以现在假设我们已经得到了N个不同步的音频样本集。 不同步的数量也可能有很大差异,所以我们要计算所有样本数的平均值。 例如,第一次调用可能表明我们不同步40ms,下一次50ms,依此类推。 但我们不会采取简单的平均值,因为最近的值比以前的值更重要。 所以我们将使用分数系数,比如c,并将差值求和:diff_sum = new_diff + diff_sum * c。 当我们准备好去找平均差异的时候,我们只需计算avg_diff = diff_sum *(1-c)。

注释:为什这里会有这么神奇的公式?嗯,它基本上是一个使用等比级数的加权平均值。我不知道这是否有名字(我甚至查过维基百科!),但是如果想要更多的信息,这里是一个解释或者weightedmean.txt。

让我们看看函数到底是什么样的:

/* Add or subtract samples to get a better sync, return newaudio buffer size */
int synchronize_audio(VideoState *is, short *samples,int samples_size, double pts) {int n;double ref_clock;n = 2 * is->audio_st->codec->channels;if(is->av_sync_type != AV_SYNC_AUDIO_MASTER) {double diff, avg_diff;int wanted_size, min_size, max_size, nb_samples;ref_clock = get_master_clock(is);diff = get_audio_clock(is) - ref_clock;if(diff < AV_NOSYNC_THRESHOLD) {// accumulate the diffsis->audio_diff_cum = diff + is->audio_diff_avg_coef* is->audio_diff_cum;if(is->audio_diff_avg_count < AUDIO_DIFF_AVG_NB) {is->audio_diff_avg_count++;} else {avg_diff = is->audio_diff_cum * (1.0 - is->audio_diff_avg_coef);/* Shrinking/expanding buffer code.... */}} else {/* difference is TOO big; reset diff stuff */is->audio_diff_avg_count = 0;is->audio_diff_cum = 0;}}return samples_size;
}

现在我们已经做得很好;我们已经近似的知道如何用视频或者其它的时钟来调整音频了。现在让我们把以下代码放在“缩小/扩大缓冲区代码”所在的位置来计算我们需要添加或删除的样本数量:

if(fabs(avg_diff) >= is->audio_diff_threshold) {wanted_size = samples_size + ((int)(diff * is->audio_st->codec->sample_rate) * n);min_size = samples_size * ((100 - SAMPLE_CORRECTION_PERCENT_MAX)/ 100);max_size = samples_size * ((100 + SAMPLE_CORRECTION_PERCENT_MAX) / 100);if(wanted_size < min_size) {wanted_size = min_size;} else if (wanted_size > max_size) {wanted_size = max_size;}

记住*audio_length * (sample_rate * # of channels * 2)*就是audio_length秒时间的声音样本数。所以,我们想要的样本数就是我们根据声音偏移添加或者减少后的声音样本数。我们也可以设置一个范围来限定我们一次进行修正的长度,因为如果我们改变的太多,用户会听到刺耳的声音。

修正样本数

现在我们要真正的修正一下声音。你可能会注意到我们的同步函数synchronize_audio返回了样本数,这可以告诉我们有多少个字节被送到流中。所以我们只要调整样本数为wanted_size就可以了。这会让样本更小一些。但是如果我们想让它变大,我们不能只是让样本大小变大,因为在缓冲区中没有多余的数据!所以我们必需添加上去。但是我们怎样来添加呢?最笨的办法就是试着来推算声音,所以让我们用已有的数据在缓冲的末尾添加上最后的样本。

if(wanted_size < samples_size) {/* remove samples */samples_size = wanted_size;
} else if(wanted_size > samples_size) {uint8_t *samples_end, *q;int nb;/* add samples by copying final samples */nb = (samples_size - wanted_size);samples_end = (uint8_t *)samples + samples_size - n;q = samples_end + n;while(nb > 0) {memcpy(q, samples_end, n);q += n;nb -= n;}samples_size = wanted_size;
}

现在我们通过这个函数得到了样本数。现在要做的就是使用它:

void audio_callback(void *userdata, Uint8 *stream, int len) {VideoState *is = (VideoState *)userdata;int len1, audio_size;double pts;while(len > 0) {if(is->audio_buf_index >= is->audio_buf_size) {/* We have already sent all our data; get more */audio_size = audio_decode_frame(is, is->audio_buf, sizeof(is->audio_buf), &pts);if(audio_size < 0) {/* If error, output silence */is->audio_buf_size = 1024;memset(is->audio_buf, 0, is->audio_buf_size);} else {audio_size = synchronize_audio(is, (int16_t *)is->audio_buf,audio_size, pts);is->audio_buf_size = audio_size;

我们要做的是把函数synchronize_audio插入进去。(同时,保证在初始化上面变量的时候检查一下代码,这些我没有赘述)。

结束之前的最后一件事情:我们需要添加一个if语句来保证我们不会以视频为主时钟的时候还来同步视频。

if(is->av_sync_type != AV_SYNC_VIDEO_MASTER) {ref_clock = get_master_clock(is);diff = vp->pts - ref_clock;/* Skip or repeat the frame. Take delay into accountFFPlay still doesn't "know if this is the best guess." */sync_threshold = (delay > AV_SYNC_THRESHOLD) ? delay :AV_SYNC_THRESHOLD;if(fabs(diff) < AV_NOSYNC_THRESHOLD) {if(diff <= -sync_threshold) {delay = 0;} else if(diff >= sync_threshold) {delay = 2 * delay;}}
}

添加后就可以了。要保证整个程序中我没有赘述的变量都被初始化过了。然后编译它:

gcc -o tutorial06 tutorial06.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

然后你就可以运行它了。

下次我们要做的是让你可以让电影快退和快进。

Tutorial 07: Seeking

源代码:tutorial07.c

处理seek命令

现在让我们的播放器具有一些seek功能,因为如果你不能跳播一部电影是很让人不舒服的。同时,你会了解到av_seek_frame函数是多么容易使用。

我们将在电影播放中使用左方向键和右方向键来表示向后和向前一小段,使用向上和向下键来表示向前和向后一大段。这里一小段是10秒,一大段是60秒。所以我们需要设置我们的主循环来捕捉键盘事件。然而当我们捕捉到键盘事件后我们不能直接调用av_seek_frame函数。我们要在循环解码主线程decode_thread中做这些。所以,我们要添加一些变量到大结构体中,用来表示跳转位置和一些跳转标志:

  int             seek_req;int             seek_flags;int64_t         seek_pos;

现在让我们在主循环中捕捉按键:

 for(;;) {double incr, pos;SDL_WaitEvent(&event);switch(event.type) {case SDL_KEYDOWN:switch(event.key.keysym.sym) {case SDLK_LEFT:incr = -10.0;goto do_seek;case SDLK_RIGHT:incr = 10.0;goto do_seek;case SDLK_UP:incr = 60.0;goto do_seek;case SDLK_DOWN:incr = -60.0;goto do_seek;do_seek:if(global_video_state) {pos = get_master_clock(global_video_state);pos += incr;stream_seek(global_video_state, (int64_t)(pos * AV_TIME_BASE), incr);}break;default:break;}break;

为了检测按键,我们先查了一下是否有SDL_KEYDOWN事件。然后我们使用event.key.keysym.sym来判断哪个按键被按下。一旦我们知道了采用何种方式去跳转,我们就来计算新的播放时间,方法为把增加的时间值加到从函数get_master_clock中得到的时间值上。然后我们调用 stream_seek函数来设置seek_pos等变量的值。我们把新的时间转换成为avcodec中的内部时间戳单位。回想一下,流中的时间戳是以帧而不是秒来度量的,公式为seconds = frames * time_base(fps)。默认的avcodec值为1,000,000fps(所以2秒的内部时间戳为2,000,000)。在后面我们来看一下为什么要转换这个值。

这就是我们的stream_seek函数。请注意如果我们倒退,我们会设置一个标志:

void stream_seek(VideoState *is, int64_t pos, int rel) {if(!is->seek_req) {is->seek_pos = pos;is->seek_flags = rel < 0 ? AVSEEK_FLAG_BACKWARD : 0;is->seek_req = 1;}
}

现在让我们回到decode_thread中来实现跳转。你会注意到我们已经在源文件做了注释,叫做"seek stuff goes here",现在我们就在这里实现代码。

跳转功能是围绕着av_seek_frame函数来实现的。这个函数用到了一个格式上下文,一个流,一个时间戳和一组标记来作为它的参数。这个函数将会跳转到你所给的时间戳的位置。时间戳的单位是你传递给函数的流的time_base。然而,你并不是必需要传给它一个流(流可以用-1来代替)。如果你这样做了,time_base将会是avcodec中的内部时间戳单位,或者是1000000fps。这就是为什么我们在设置seek_pos的时候会把位置乘以AV_TIME_BASER的原因。

但是,如果给av_seek_frame函数的stream参数值为-1,你有时会在播放某些文件的时候遇到问题(比较少见),所以我们会取文件中的第一个流并且把它传递到av_seek_frame函数。不要忘记我们也要把时间戳timestamp的单位进行转化。

if(is->seek_req) {int stream_index= -1;int64_t seek_target = is->seek_pos;if     (is->videoStream >= 0) stream_index = is->videoStream;else if(is->audioStream >= 0) stream_index = is->audioStream;if(stream_index>=0){seek_target= av_rescale_q(seek_target, AV_TIME_BASE_Q,pFormatCtx->streams[stream_index]->time_base);}if(av_seek_frame(is->pFormatCtx, stream_index, seek_target, is->seek_flags) < 0) {fprintf(stderr, "%s: error while seeking\n",is->pFormatCtx->filename);} else {/* handle packet queues... more later... */

这里av_rescale_q(a,b,c)函数是用来把时间戳从一个时基调整到另外一个时基。它基本上是计算a*b/c,但是这个函数还是必需的,因为直接计算会有溢出的情况发生。 AV_TIME_BASE_Q是AV_TIME_BASE的小数版本。 它们完全不同:AV_TIME_BASE * time_in_seconds = avcodec_timestamp,而AV_TIME_BASE_Q * avcodec_timestamp = time_in_seconds(但请注意,AV_TIME_BASE_Q实际上是一个AVRational对象,因此您必须在avcodec中使用特定的q函数来处理它)。

刷新缓冲区

我们已经正确设定了跳转位置,但是我们还没有完成。记住我们有一个存放了很多包的队列。既然我们跳到了不同的位置,我们必需把队列中的内容刷新否则电影是不会跳转的。不仅如此,avcodec也有它自己的内部缓冲,也需要每次被刷新。

要实现这个,我们需要首先写一个函数来清空我们的包队列。然后我们需要一种命令去告诉音频和视频线程需要去刷新avcodec内部缓冲。我们可以在刷新队列后把特定的包放入到队列中,然后当它们检测到特定的包的时候,它们就会去刷新自己的内部缓冲区。

让我们开始写刷新函数。其实很简单的,所以直接看代码:

static void packet_queue_flush(PacketQueue *q) {AVPacketList *pkt, *pkt1;SDL_LockMutex(q->mutex);for(pkt = q->first_pkt; pkt != NULL; pkt = pkt1) {pkt1 = pkt->next;av_free_packet(&pkt->pkt);av_freep(&pkt);}q->last_pkt = NULL;q->first_pkt = NULL;q->nb_packets = 0;q->size = 0;SDL_UnlockMutex(q->mutex);
}

现在已经刷新了队列,我们放入“刷新包”。但是开始我们要定义和创建这个包:

AVPacket flush_pkt;main() {...av_init_packet(&flush_pkt);flush_pkt.data = "FLUSH";...
}

现在我们把这个包加入队列:

 } else {if(is->audioStream >= 0) {packet_queue_flush(&is->audioq);packet_queue_put(&is->audioq, &flush_pkt);}if(is->videoStream >= 0) {packet_queue_flush(&is->videoq);packet_queue_put(&is->videoq, &flush_pkt);}}is->seek_req = 0;
}

(这个代码片段是接着前面decode_thread中的代码片段的)我们也需要修改packet_queue_put函数,以便特定的“刷新包”能够加入其中:

int packet_queue_put(PacketQueue *q, AVPacket *pkt) {AVPacketList *pkt1;if(pkt != &flush_pkt && av_dup_packet(pkt) < 0) {return -1;}

然后在音频线程和视频线程中,我们在packet_queue_get后立即调用函数avcodec_flush_buffers:

    if(packet_queue_get(&is->audioq, pkt, 1) < 0) {return -1;}if(pkt->data == flush_pkt.data) {avcodec_flush_buffers(is->audio_st->codec);continue;}

上面的代码片段与视频线程中的一样,只要把“audio”换成“video”。

至此,让我们编译我们的播放器:

gcc -o tutorial07 tutorial07.c -lavutil -lavformat -lavcodec -lswscale -lz -lm \

`sdl-config --cflags --libs`

来欣赏一下不到1000行C代码制作的电影播放器! 当然,我们还有很多东西可以添加。

结语

我们已经有了一个可以工作的播放器,但是它肯定还不够好。我们做了很多,但是我们还可以添加许多其他功能:

事实上,这个播放器还是很糟糕的。它所基于的ffplay.c版本已经完全过时,因此本教程需要进行重大变革。如果你想在比较正式的项目中使用FFmpeg库,我恳请你查看最新版本的ffplay.c。

  • 错误处理:我们代码中的错误处理非常糟糕,可以更好地处理。
  • 暂停:我们不能暂停电影,这是一个很有用的功能。我们可以在大结构体中使用一个内部暂停变量,当用户暂停的时候就设置它。然后我们的音频,视频和解码线程检测到它后就不再输出任何东西。我们也使用av_read_play来支持网络。这很容易解释,但是你却不能很容易弄明白,所以把这个作为一个家庭作业,如果你想尝试的话。提示,可以参考ffplay.c。
  • 支持硬解码
  • 按字节跳转:如果你可以按照字节而不是秒的方式来计算出跳转位置,那么对于像VOB这种有不连续时间戳的视频文件来说,定位会更加精确。
  • 帧丢弃:如果视频落后的太多,我们应当把下一帧丢弃而不是设置一个短的刷新时间。
  • 网络支持:此播放器无法播放网络流媒体视频。
  • 支持像YUV原始视频数据:如果我们的播放器支持像YUV原始视频数据,我们必须设置一些选项,因为我们无法猜测大小或time_base。
  • 全屏
  • 多种选择:例如:不同图像格式;参考ffplay.c中的命令开关。

如果你想了解更多关于FFmpeg的信息,我们只讨论了它的一部分。下一步将是研究如何编码多媒体。一个好的起点是FFmpeg发行版中的output_example.c文件。我可能会写另一个教程,但我可能无法完成。

UPDATE我已经很久没有更新这个教程了,音视频领域已经变得更加成熟。本教程仅需要简单的API更新;在基本概念方面,实际上没有什么变化。大多数更新实际上是简化代码。然而,虽然我已经完成并更新了这里的代码,但ffplay仍然完全胜过这个toy player。让我们坦率地说,它无法作为一个真正的电影播放器​​使用。因此,如果您或未来的您希望改进本教程,请研究ffplay并找出我们遗漏的内容。我猜测它主要利用硬件,但也许我错过了一些明显的东西。 ffplay可能会彻底改变一些事情;但我还没看。

但我很自豪,多年来它仍然帮助了很多人,即使你不得不去其他地方获取代码。 我非常感谢chelyaev,他替换了8年前写这篇文章以来所有被弃用的函数。

好吧,我希望这个教程很有启发性和乐趣。 如果您对本教程有任何建议,bugs,投诉,赞誉等,请发送电子邮件至 dranger at gmail dot com。 请不要在你其他FFmpeg项目中向我求助。 我收到太多这些电子邮件。

参考

  • 如何用FFmpeg编写一个简单播放器详细步骤介绍
  • FFMPEG视音频编解码零基础学习方法

FFmpeg+SDL,如何用少于1000行代码编写视频播放器相关推荐

  1. 如何基于FFMPEG和SDL写一个少于1000行代码的视频播放器

    http://blog.csdn.net/eplaylity/archive/2008/12/05/3454431.aspx http://www.cnblogs.com/konyel/tag/SDL ...

  2. sql行数少于10_如何用少于100行的代码创建生成艺术

    sql行数少于10 by Eric Davidson 埃里克·戴维森(Eric Davidson) 如何用少于100行的代码创建生成艺术 (How to Create Generative Art I ...

  3. FFmpegSDL教程:用不到1000行代码写一个播放器

    序 更新:本教程最近一次更新于2015年2月. FFmpeg是一个创建视频应用,甚至更通用程序的强大音视频工具库.FFmpeg能够完成视频处理过程中解码,编码,封装和解封装所有这些棘手工作.借助它,可 ...

  4. 让你少写 1000 行代码的正则全攻略来了!

    说起正则表达式,相信大家都不陌生.在很多程序员的认知中,这东西并不难,在工作中用的也不多,每次用的时候去 Google 搜一搜,然后复制过来改一改,问题就解决了,看起来效率特别高,好像完全没必要专门花 ...

  5. 1000行代码入门python-Python基础知识和工作环境

    Python基础知识和工作环境 第一堂课的教学目标是: [1]掌握Python的基础知识,比如,谁是Python的创始人,它有哪些特点: [2]Python的开发工具Anaconda的操作界面是怎样的 ...

  6. 1000行代码入门python-小白入门篇,Python到底是什么?

    原标题:小白入门篇,Python到底是什么? Python是一种计算机编程语言 他和英语,法语,韩语,猫语,狗语一样 很抱歉,说梦话不算 想一想,如果你会猫语,可以和猫咪对话,那是不是很有意思? Py ...

  7. 1000行代码写小游戏(终)

    最后献上完整的1000行代码,基本功能已经完成,可以通过配置小怪和矿的位置和大小控制玩家时长和难度: ------------------------------------------------- ...

  8. VB.Net程序设计:CodeStringHelper多行代码批量修改器

    CodeStringHelper多行代码批量修改器源码 VB.Net程序设计过程中,经常需要对多行代码批量修改,特意将经常遇到过的类型集合在一起.源码分享! 支持功能: 1.删除多余的空白行(多用于复 ...

  9. 每 1000 行代码有 14 个安全缺陷,开源软件的安全令人堪忧

     聚焦源代码安全,网罗国内外最新资讯! 编译:奇安信代码卫士团队 当今,开源软件已经成为软件世界的重要组成部分.根据 Gartner 统计,99% 的组织在其 IT 系统中使用了开源软件. 今年 3 ...

最新文章

  1. latex不能识别eps图片
  2. 快速失败Vs安全失败(Java迭代器附示例)
  3. 数据结构-直接寻址表
  4. 3D人脸重建——PRNet网络输出的理解
  5. Microsoft PHP.Net ?
  6. oracle 游标 904,如何解决Oracle数据库游标连接超出问题
  7. ntext在mysql_varchar和text说不清的那些事
  8. maven内存不足:Unexpected error occurred: Not enough memory to allocate buffers for rehashing Java heap
  9. 【答题卡识别】基于matlab GUI hough变换答题卡成绩统计(带面板)【含Matlab源码 1828期】
  10. 卸载程序_Windows 7 如何卸载或删除应用和程序,我教你
  11. 柴静自费百万调查雾霾真相
  12. Apabi Reader 4.0.1正式发布!
  13. Unity网格编程篇(二) 非常详细的Mesh编程入门文章
  14. 记一次CTFd平台搭建
  15. 2020蓝天杯论文评比系统_2020年杭州市初中数学核心组教师高级研修培训系列视频(十一)...
  16. android 截取验证码的两种实现方式
  17. 互联网晚报 | 12月30日 星期四 | 百度网盘青春版正式上线;汽车之家回应“年底大裁员”;A股年内成交额创历史新高...
  18. 2013年:各大IT公司待遇【转载】
  19. 抓包工具wireshark和Fiddler的使用
  20. GitHub Pages + Hexo搭建个人博客网站,史上最全教程

热门文章

  1. 转自H3C:测试工具和测试自动化
  2. 笔记--关于offsetLeft和left的区别
  3. Cognex Designer中的数据显示-----你不知道的BUG
  4. 微信订阅号开发模式基本配置
  5. 为什么期货投资70%的亏钱,20%不亏不赚,10%是赚钱?
  6. 六十星系之27七杀独坐寅申
  7. 计算机视觉——顶会、顶刊
  8. 内存不能为read九大原因
  9. PDT各代表职责(案例)
  10. Web报表开发:ireport