FFmpeg 播放器入门教程(4):线程分治

本教程分为 7 篇,将逐步讲解如何基于 FFmpeg 的 API 用 1000 行左右的代码开发一个简易播放器,非常适合初学者学习音视频开发。本文为第 4 篇:《FFmpeg 播放器入门教程(4):线程分治》。

—— 内容来自公众号:关键帧Keyframe 的分享。

1、概览

上一节教程中,我们使用 SDL 的音频相关的函数来支持音频播放。SDL 起了一个线程来在需要音频数据的时候去调用我们定义的回调方法。现在我们要做的是用线程的方法去改造视频显示这块的逻辑。这样一来会使得代码的机构更模块化,这样改动起来会更简单,尤其是当我们想添加音视频同步逻辑时。

我们从哪开始呢?首先,我们发现我们的 main 函数做的事情太多了:运行 event loop、读取数据包、进行视频解码等等,所以我们现在要做的就是把这些事情拆分掉:一个线程来专门做数据包解码,这些数据包会被添加到队列中,并分别被音频处理线程和视频处理线程来读取和处理。音频处理线程我们在上一节已经按照设想写好了,视频处理线程要复杂一些,因为我们需要自己来显示视频。我们要把显示视频的代码放到 main loop,但是不会每次循环的时候去做显示,而是把视频显示集成 event loop 中去。方式就是解码视频数据,把视频帧存到另一个队列,然后创建一个自定义事件(FF_REFRESH_EVENT)添加到事件系统,然后当 event loop 处理这个事件时就会显示队列中的下一帧。下面我们用个图来说明下这个流程:

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

把视频显示的逻辑放到 event loop 中去的主要目的是为了使用 SDL_Delay 线程,这样我们可以准确的控制下一个视频帧什么时候显示到屏幕上。在下一节教程中当我们要做音视频同步时,相关的逻辑就会变得简单些了。

2、精简代码

我们现在要创建一个比较大的结构体来容纳所有音视频信息,叫做 VideoState

typedef struct VideoState {
 AVFormatContext *pFormatCtx;
 int videoStream, audioStream;
 AVStream *audio_st;
 PacketQueue audioq;
 uint8_t audio_buf[(MAX_AUDIO_FRAME_SIZE * 3) / 2];
 unsigned int audio_buf_size;
 unsigned int audio_buf_index;
 AVFrame audio_frame;
 AVPacket audio_pkt;
 uint8_t *audio_pkt_data;
 int audio_pkt_size;
 AVStream *video_st;
 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;
 
 AVIOContext *io_context;
 struct SwsContext *sws_ctx;
} VideoState;

简单看看 VideoState 中都有什么。首先,格式信息 pFormatCtx;视频和音频流的标记 videoStreamaudioStream,以及对应的视频和音频流对象 audio_stvideo_st;接下来,是我们移过来的音频缓冲区相关的数据:audio_bufaudio_buf_sizeaudio_buf_index 等;我们还添加了视频数据队列 videoq、视频数据缓冲区 pictq 来存储解码后的视频帧,VideoPicture 是我们自己创建的数据结构,我们后面再看里面都有些啥;我们还增加了两个指向对应线程的指针:parse_tidvideo_tid;此外还有退出标志 quit,媒体文件名 filename 等等。

现在我们回到 main 函数来看看我们的程序都有哪些改变。首先,我们创建 VideoState 并分配内存:

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

av_mallocz() 会为我们分配内存并将内存初始化为 0。

接着,初始化视频渲染相关数据缓冲区 pictq 的锁。因为事件循环调用我们的渲染函数时,渲染逻辑就会从 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 提供的一个字符串拷贝方法,增加了一些边界检查功能。

3、第一个线程

现在我们启动 decode_thread() 线程来开始工作:

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 事件。这个事件将在事件队列里触发 video refresh 函数的调用。不过,现在我们还是先来看看 SDL_CreateThread() 函数。

SDL_CreateThread() 函数会分发一个新的线程,这个线程有原进程的所有内存的访问权限,并从我们指定的函数开始运行。这个线程会给指定的函数传入一个用户定义的数据作为参数,在我们这里,我们调用的函数是 decode_thread() 传入的参数是前面初始化的 VideoStatedecode_thread() 的前半部分没有什么新鲜的:打开媒体文件找到视频流和音频流的索引。这里与之前唯一的不同就是 AVFormatContext 被我们放到 VideoState 中去了。在找到了视频流和音频流后,我们接下来就调用另一个我们将实现的函数: stream_component_open()。这里我们就将代码模块化了,对重复的工作完成了一些代码复用。

stream_component_open() 函数主要用于帮我们找到对应的解码器、创建对应的音频配置、保存关键信息到 VideoState、启动音频和视频线程。这个函数也是我们添加其他配置的地方,比如强制使用给定的 codec 而不是自动检测等等。代码如下:

int stream_component_open(VideoState *is, int stream_index) {
 
 AVFormatContext *pFormatCtx = is->pFormatCtx;
 AVCodecContext *codecCtx = NULL;
 AVCodec *codec = NULL;
 AVDictionary *optionsDict = NULL;
 SDL_AudioSpec wanted_spec, spec;
 
 if (stream_index < 0 || stream_index >= pFormatCtx->nb_streams) {
  return -1;
 }
 
 // Get a pointer to the codec context for the video stream.
 codecCtx = pFormatCtx->streams[stream_index]->codec;
 
 if (codecCtx->codec_type == AVMEDIA_TYPE_AUDIO) {
  // Set audio settings from codec info.
  wanted_spec.freq = codecCtx->sample_rate;
  wanted_spec.format = AUDIO_S16SYS;
  wanted_spec.channels = codecCtx->channels;
  wanted_spec.silence = 0;
  wanted_spec.samples = SDL_AUDIO_BUFFER_SIZE;
  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;
  }
 }
 codec = avcodec_find_decoder(codecCtx->codec_id);
 if (!codec || (avcodec_open2(codecCtx, codec, &optionsDict) < 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_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];
   
   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, AV_PIX_FMT_YUV420P, SWS_BILINEAR, NULL, NULL, NULL);
   break;
  default:
   break;
 }
 return 0;
}

上面的函数主要是服务于音频和视频,这里把 VideoState 作为回调函数的参数数据。同时,我们保存了 audio_stvideo_st,还初始化了视频队列 videoq 和音频队列 audioq。最重要的是,我们在这里启动了音频线程和视频线程。

SDL_PauseAudio(0);
break;

// ...... 

is->video_tid = SDL_CreateThread(video_thread, is);

我们接着看看 decode_thread() 的后半部分,这部分的主要工作是通过一个循环来读取 packet 并把它放入正确的队列:

int decode_thread(void *arg) {

 // ... code ...

 // Main decode loop.
 for (;;) {
  if (is->quit) {
   break;
  }
  // Seek stuff goes here.
  if (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_packet_unref(packet);
  }
 }

 // All done - wait for it.
 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;
}

上面代码的 for 循环中,我们为音频队列和视频队列添加了 max size,还增加了对读数据错误的检查。AVFormatContext *pFormatCtx 有一个 ByteIOContext 成员,这个成员会记录所有底层文件信息。

在循环完成后,接下来的逻辑就是等待其他任务结束,以及发出通知告诉其他任务我们这已经结束了。这段扫尾代码也演示了如何发事件。

我们通过 SDL 提供的常量 SDL_USEREVENT 来取得用户事件,第一个用户事件的值为 SDL_USEREVENT,往后则都是累加 1。比如,FF_QUIT_EVENT 事件在我们的程序中的值是 SDL_USEREVENT + 1。我们还可以给事件附加上用户数据,我们的程序中,我们把用户数据的指针指向了 is。最后我们调用 SDL_PushEvent() 函数将事件发布出去。在后续的事件处理逻辑中,我们将遍历和处理事件。现在要明确的就是我们这里发出了 FF_QUIT_EVENT 事件,我们将获取这个事件并将 quit 标志置为 1。

4、获取帧:video_thread

在 codec 准备好后,我们启动 video thread。这个线程从 video queue 中读取数据包 packet,解码为视频帧,然后调用 queue_picture() 函数将处理好的帧添加到 picture queue。

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 packets.
   break;
  }
  // Decode video frame.
  avcodec_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_packet_unref(packet);
 }
 av_free(pFrame);
 return 0;
}

这里的代码还是比较清晰的,我们把 avcodec_decode_video2() 函数挪到了这里,由于很多信息被我们放到了 VideoState 中,所以这里的参数我们做了改变,比如:我们通过 is->video_st->codecVideoState 中获取视频的 codec。我们持续从 video queue 中获取 packet 数据包,直到有人告诉我们 quit 或者遇到错误。

5、帧队列

接着,我们看一下存储解码帧的函数 queue_picture(),由于我们的 picture queue 里放的是 SDL overlay,所以我们需要把视频帧转换为 SDL overlay。

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

VideoState 中有个缓冲区用来存储 VideoPicture,但是我们需要自己创建和分配 SDL_Overlay 的内存,注意,allocated 就是用来标记我们有没有做这件事。

我们需要两个指针来帮助我们使用这个队列:写索引和读索引。我们同时也记录缓冲区有多少图像。当要往队列写入数据时,我们首先要等缓冲区清理出空间来存放 VideoPicture。然后我们检查我们是否在写索引位置创建了 SDL overlay,如果没有则需要分配对应的内存。如果窗口的尺寸发生改变了,我们还要重新创建缓冲区。

int queue_picture(VideoState *is, AVFrame *pFrame) {
 
 VideoPicture *vp;
 AVFrame 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 initially.
 vp = &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;
  // We have to do it in the main thread.
  event.type = FF_ALLOC_EVENT;
  event.user.data1 = is;
  SDL_PushEvent(&event);
  
  // Wait until we have a picture allocated.
  SDL_LockMutex(is->pictq_mutex);
  while (!vp->allocated && !is->quit) {
   SDL_CondWait(is->pictq_cond, is->pictq_mutex);
  }
  SDL_UnlockMutex(is->pictq_mutex);
  if (is->quit) {
   return -1;
  }
 }
 
 // ... code ...

}

这里我们发送了一个事件 FF_ALLOC_EVENT,处理这个事件的代码在主线程中:

case FF_ALLOC_EVENT:
 alloc_picture(event.user.data1);
 break;

这里调用了 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/smaller.
  SDL_FreeYUVOverlay(vp->bmp);
 }
 // Allocate a place to put our YUV image on that screen.
 SDL_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;
 
 SDL_LockMutex(is->pictq_mutex);
 vp->allocated = 1;
 SDL_CondSignal(is->pictq_cond);
 SDL_UnlockMutex(is->pictq_mutex);
 
}

我们把 SDL_CreateYUVOverlay() 从 main 函数中移到了这里,现在我们对这个函数加了锁,因为有两个线程可以同时往屏幕写数据,这样能防止 alloc_picture() 函数和显示视频的函数发生冲突。需要注意我们在 VideoPicture 中记录了视频的宽度和高度,因为我们需要确保我们的视频尺寸不会发生改变。

现在我们已经创建了 YUV overlay 并分配了内存,我们做好了接收图像的准备。现在我们回到 queue_picture() 函数来看看拷贝视频帧到 YUV overlay 的这部分代码:

int queue_picture(VideoState *is, AVFrame *pFrame) {

 // Allocate a frame if we need it... 
 // ... code ...
 // We have a place to put our picture on the queue 

 // We have a place to put our picture on the queue.
 if (vp->bmp) {
  
  SDL_LockYUVOverlay(vp->bmp);
  
  // 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 uses.
  sws_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 的值,所以这里我们要给它加锁。我们在这里做的就是,将写指针递增,然后锁住队列并增加它的 size。然后读数据方会知道队列中有数据了,如果队列满了,我们写数据方也会知道。

6、显示视频

上面介绍完了 video thread,我们接下来来看看 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_AddTimer() 是一个 SDL 的函数,用来在指定的时间(ms)后回调用户指定的函数,当然还可以选择带上用户指定的数据。我们将用 schedule_refresh 这个函数来做图像更新:每次我们调用这个函数,它就会设置一个定时器,这个定时器会触发一个事件来让 main 函数的事件处理逻辑去从 picture queue 取得一帧数据来显示出来。

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.
}

sdl_refresh_timer_cb() 就是定时器会触发调用的那个用于发事件的函数,FF_REFRESH_EVENT 事件被定义为了 SDL_USEREVENT + 1。主要注意的是当我们在这里返回 0 时,SDL 会停止这个定时器,这样也就停止去调用这个回调了。

既然我们这里发出了 FF_REFRESH_EVENT 事件,那么就需要有地方处理它,这个地方就在 main 函数的 event loop 中:

for (;;) {
 
 SDL_WaitEvent(&event);
 switch(event.type) {

  // ... code ...
  
  case FF_REFRESH_EVENT:
   video_refresh_timer(event.user.data1);
   break;

  // ... code ...

 }
}

从这里可以看到,处理这个事件的函数是 video_refresh_timer()

void video_refresh_timer(void *userdata) {
 
 VideoState *is = (VideoState *)userdata;
 // vp is used in later tutorials for synchronization.
 VideoPicture *vp;
 
 if (is->video_st) {
  if (is->pictq_size == 0) {
   schedule_refresh(is, 1);
  } else {
   vp = &is->pictq[is->pictq_rindex];

   // Now, normally here goes a ton of code about timing, etc. we're just going to guess at a delay for now. You can increase and decrease this value and hard code the timing - but I don't suggest that, We'll learn how to do it for real later.
   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);
 }
}

这是一个比较简单的函数:当 pictq 队列有数据时就取出 VideoPicture,设置显示下一帧图像的 timer,调用 video_display() 来将视频显示出来,增加队列的计数器,更新队列的 size。你可能注意到了,这里我们虽然取出了一个 VideoPicture 但并没有使用它,原因是我们后面会用到。后面我们会用这个 VideoPicture 的时间信息来做音视频同步相关的工作,其中的时间信息将告诉我们该何时显示下一帧图像,我们会把这个时间信息传给 schedule_refresh()。而现在,我们只是简单的传了一个 80。

现在我们要做的最后一件事就是 video_display() 函数:

void video_display(VideoState *is) {
 SDL_Rect rect;
 VideoPicture *vp;
 float aspect_ratio;
 int w, h, x, y;
 
 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);
 }
}

由于我们的屏幕可以是任意尺寸(我们自己设置的是 640×480,但是这个对用户应该是可以改变的),所以我们需要能够动态地计算我们要显示图像的尺寸。首先,我们需要计算出视频的 aspect ratio,即宽度和高度的比例(width/height)。但是有一些 codec 有很奇怪的 sample aspect ration,即单像素(单采样)的宽高比(width/height),又由于我们的 AVCodecContext 中的宽度和高度是以像素为单位来表示的,那么这时候 actual aspect ratio 应该是 aspect ratio 乘上 sample aspect ratio。有的 codec 的 aspect ratio 值是 0,这表示的是每个像素的尺寸是 1×1。

接下来,我们放大视频来尽量适配我们的屏幕。代码中的 & -3 位操作可以将数值调整到最接近 4 的倍数,然后我们将视频居中,并调用 SDL_DisplayYUVOverlay(),这里要确保我们通过 screen_mutex 来加锁。

到这里,我们还需要用新的 VideoState 来重写音频处理的代码,但这里的工作还是比较少的,参见样例代码。最后我们要修改一下 FFmpeg 的内部退出回调对应的函数:

// Since we only have one decoding thread, the Big Struct can be global in case we need it.
VideoState *global_video_state;

int decode_interrupt_cb(void *opaque) {
 return (global_video_state && global_video_state->quit);
}

我们在主函数设置 global_video_stateVideoState *is

7、编译执行

教程中所有的源码,可以到我们的知识星球下载:

FFmpeg 播放器入门教程(4):线程分治
FFmpeg 播放器入门教程(4):线程分治
扫码加入

下载源码后,你可以使用下面的命令编译它:

$ gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lz -lm `sdl-config --cflags --libs`

找一个视频文件,你可以这样执行一下试试:

$ tutorial04 myvideofile.mp4

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论