文檔4:創建線程

Code: tutorial04.c

綜述
前面我們利用SDL的音頻函數實現了對音頻解碼和播放的支持,我們定義一個包含音頻回調函數callbacks的線程函數,當我們需要音頻的時候就使SDL啓動這個線程。現在我們將要對視頻播放做同樣的事情,這樣能使代碼更容易模塊化和協作,尤其有利於音視頻同步,那麼我們從哪裏開始呢?
首先注意到,我們的主函數需要做太多的事情:運行event循環,讀packet,解碼視頻,我們需要做的就是把各個部分分開,創建一個線程負責解碼出packets,並把音頻、視頻的packet放到各自的隊列中,並由相應的音頻、視頻處理線程讀取,我們已經創建了所需要的音頻線程,視頻處理線程會有點複雜,因爲我們需要自己來播放視頻數據(音頻由SDL來播放)。我們會在主循環中添加我們的播放代碼,但是我們要把視頻播放與事件驅動循環結合起來,而不是僅僅在主循環中播放,這就意味着我們先對視頻解碼,把解碼生成的視頻幀放在另外一個隊列中,然後創建一個常規事件(FF_REFRESH_EVENT)並加到事件驅動系統,每當事件驅動循環遇到這個事件(FF_REFRESH_EVENT)就播放下一幀,下面是上述功能的一個手繪的字符圖示。

 ________ audio  _______      _____
|        | pkts |       |    |     | to spkr
| DECODE |----->| AUDIO |--->| SDL |-->
|________|      |_______|    |_____|
    |  video     _______
    |   pkts    |       |
    +---------->| VIDEO |
 ________       |_______|   _______
|       |          |       |       |
| EVENT |          +------>| VIDEO | to mon.
| LOOP  |----------------->| DISP. |-->
|_______|<---FF_REFRESH----|_______|
使用SDL的SDL_Delay線程,能夠精確控制下一個視頻幀的播放時間,這就是我們把對視頻播放的控制與事件驅動循環結合起來的主要原因,當我們在下一章最終講音視頻同步時,對於在正確的時間刷新正確的圖片,添加這部分代碼將不再是難事。

簡化代碼

我們需要對代碼做一些修剪,我們擁有所有音頻和視頻編解碼信息,還需要添加隊列和buffer和其他一些東西,所有這些東西都爲一個邏輯單元服務–電影,所以我們應該創建一個大的結構體把所有這些信息都包括進去,命名爲VideoState。

typedef struct VideoState {

  AVFormatContext *pFormatCtx;
  int             videoStream, audioStream;
  AVStream        *audio_st;
  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;
  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;
我們先大致看一下這一個結構體:基本信息AVFormatContext *pFormatCtx;音視頻流指數及其相應的AVStream實體,把與音頻有關的audio_buffer、audio_buffer_size等這些buffer也放到這個結構體中,爲視頻也創建一個隊列和buffer(用來存放解碼後的視頻幀,而不需要一個真正的隊列來存放),VideoPicture struct 是我們自己創建的結構體,當用到時會對它進行分析,爲我們創建的兩個線程分別分配指針,quit標誌,還有電影文件名。現在我們回到主函數中看看這些能使程序發生哪些變化,首先初始化VideoState結構體:
int main(int argc, char *argv[]) {

  SDL_Event       event;

  VideoState      *is;

  is = av_mallocz(sizeof(VideoState));
av_mallocz()是一個很好的函數,它爲我們分配內存並將其內容清零。 然後初始化display buffer (pictq)的互斥鎖,因爲事件驅動循環要調用播放函數,從display buffer(pictq)中取出已解碼的數據幀,同時視頻解碼函數要將其生成的數據幀放入display buffer (pictq),兩者就會產生衝突,這是一個典型的競爭情況,我們要在啓動任一線程之前定義爲其分配互斥鎖,另外把電影名拷貝到VideoState。
pstrcpy(is->filename, sizeof(is->filename), argv[1]);

is->pictq_mutex = SDL_CreateMutex();
is->pictq_cond = SDL_CreateCond();
pstrcpy()是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_CreatThread()函數,它創建一個線程運行在給定的函數,並能向此函數傳遞用戶定義數據,此線程對所在進程的內存區域具有完全的訪問權,我們就用這種方式調用decode_thread()和傳遞VideoState,這個函數的前半部分沒有什麼新鮮的東西,只是打開文件,找到音頻、視頻流,唯一不同的地方是要把format_context保存到VideoState中,再找到音頻、視頻流位置後,就調用另外一個我們將要定義的函數stream_component_open()使用這種方法將程序分開非常自然,由於對音視頻的解碼初始化功能相似,將其放到一個函數中能節省很多代碼。
在stream_component_open()函數中,我們找到codec、decoder初始化音頻選項、保存重要信息到VideoState中,啓動音頻、視頻、線程,我們也可以在此添加選項,使其能夠指定編解碼器而不是自動探測,等等,下面是函數的內容:
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;
  }

  // Get a pointer to the codec context for the video stream
  codecCtx = pFormatCtx->streams[stream_index]->codec;

  if(codecCtx->codec_type == CODEC_TYPE_AUDIO) {
    // Set audio settings from codec info
    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());
      return -1;
    }
  }
  codec = avcodec_find_decoder(codecCtx->codec_id);
  if(!codec || (avcodec_open(codecCtx, codec) < 0)) {
    fprintf(stderr, "Unsupported codec!\n");
    return -1;
  }

  switch(codecCtx->codec_type) {
  case CODEC_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 CODEC_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);
    break;
  default:
    break;
  }
}
這些代碼和前面講的基本上一樣,只不過我們將對音頻和視頻的處理結合在了一起,值得注意的是我們將大的結構體VideoState作爲audio callback的參數,而不是原來的CodecCtx,並把音頻、視頻流分別保存到了audio_st和video_st,同樣,我們創建了視頻隊列並和音頻隊列一樣初始化,然而最重要的是啓動音頻和視頻處理線程:
    SDL_PauseAudio(0);
    break;

/* ...... */

    is->video_tid = SDL_CreateThread(video_thread, is);
首先回來看一下decode_thread()函數的後半部分,它基本上是一個for循環,主要完成讀取一個packet然 後將其添加到對應的隊列中。
  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(url_ferror(&pFormatCtx->pb) == 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類型結構體。這個結構體是用來保存一些低級的文件信息。函數url_ferror用來檢測結構體並發現是否有些讀取文件錯誤。

在循環以後,我們的代碼是用等待其餘的程序結束和提示我們已經結束的。這些代碼是有益的,因爲它指示出了如何驅動事件--後面我們將顯示影像。

  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事件,我們將在後面捕捉到它並且設置我們的退出標誌quit。

獲取視頻幀:video_thread
當我們準備好解碼器後,我們開始視頻線程。這個線程從視頻隊列中讀取包,把它解碼成視頻幀,然後調用queue_picture函數把處理好的幀放入到圖片隊列中:

int video_thread(void *arg) {
  VideoState *is = (VideoState *)arg;
  AVPacket pkt1, *packet = &pkt1;
  int len1, frameFinished;
  AVFrame *pFrame;

  pFrame = avcodec_alloc_frame();

  for(;;) {
    if(packet_queue_get(&is->videoq, packet, 1) < 0) {
      // means we quit getting packets
      break;
    }
    // Decode video frame
    len1 = avcodec_decode_video(is->video_st->codec, pFrame, &frameFinished, 
				packet->data, packet->size);

    // 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。然後我們檢查看我們是否已經申請到了一個可以寫入覆蓋的索引號。如果沒有,我們要申請一段空間。我們也要重新申請緩衝如果窗口的大小已經改變。然而,爲了避免被鎖定,盡是避免在這裏申請(我現在還不太清楚原因;我相信是爲了避免在其它線程中調用SDL覆蓋函數的原因)。

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

這裏的事件機制與前面我們想要退出的時候看到的一樣。我們已經定義了事件FF_ALLOC_EVENT作爲SDL_USEREVENT。我們把事件發到事件隊列中然後等待申請內存的函數設置好條件變量。

讓我們來看一看如何來修改事件循環:

for(;;) {
  SDL_WaitEvent(&event);
  switch(event.type) {
/* ... */  
  case FF_ALLOC_EVENT:
    alloc_picture(event.user.data1);
    break;
記住event.user.data1是我們的大結構體。就這麼簡單。讓我們看一下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
  vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width,
				 is->video_st->codec->height,
				 SDL_YV12_OVERLAY,
				 screen);
  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函數從主循環中移到了這裏。這段代碼應該完全可以自我註釋。記住我們把高度和寬度保存到VideoPicture結構體中因爲我們需要保存我們的視頻的大小沒有因爲某些原因而改變。

好,我們幾乎已經全部解決並且可以申請到YUV覆蓋和準備好接收圖像。讓我們回顧一下queue_picture並看一個拷貝幀到覆蓋的代碼。你應該能認出其中的一部分:

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 uses
    img_convert(&pict, dst_pix_fmt,
		(AVPicture *)pFrame, is->video_st->codec->pix_fmt, 
		is->video_st->codec->width, is->video_st->codec->height);
    
    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覆蓋。最後一點只是簡單的給隊列加1。這個隊列在寫的時候會一直寫入到滿爲止,在讀的時候會一直讀空爲止。因此所有的都依賴於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_AddTimer()是SDL中的一個定時(特定的毫秒)執行用戶定義的回調函數(可以帶一些參數user data)的簡單函數。我們將用這個函數來定時刷新視頻--每次我們調用這個函數的時候,它將設置一個定時器來觸發定時事件來把一幀從圖像隊列中顯示到屏幕上。

但是,讓我們先觸發那個事件。

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做一些實際的動作,原因是這樣的:我們將在後面處理。我們將在後面同步音頻和視頻的時候用它來訪問時間信息。你會在這裏看到這個註釋信息”timing密碼here”。那裏我們將討論什麼時候顯示下一幀視頻,然後把相應的值寫入到schedule_refresh()函數中。現在我們只是隨便寫入一個值80。從技術上來講,你可以猜測並驗證這個值,並且爲每個電影重新編譯程序,但是:1)過一段時間它會漂移;2)這種方式是很笨的。我們將在後面來討論它。

我們快完成了,只需要再做一件事:顯示視頻。下面是顯示視頻的函數:

void video_display(VideoState *is) {

  SDL_Rect rect;
  VideoPicture *vp;
  AVPicture pict;
  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_DisplayYUVOverlay(vp->bmp, &rect);
  }
}
顯示尺寸可以是任意大小(代碼中我們設置的尺寸是640x480,可以讓用戶隨意的改變它的大小。),我們需要動態的改變視頻框的大小。首先我們需要知道視頻的寬高比,也就是視頻寬度除以視頻高度得到的比值。有些解碼器會得到個奇怪的寬高比,它是簡單的寬高分辨率的比值。有些解碼器顯示的寬高比值爲0,這表示每個像素僅僅是尺寸1x1。這時,我們可以縮放視頻大小到屏幕大小。 與 -3 進行與操作(即&-3,見上面代碼),最接近4的倍數,接着,我們把電影顯示在屏幕中間,調用SDL_DisplayYUVOverlay()函數進行顯示.
就是這樣嗎?我們全完成了嗎? 呵呵,我們還需要使用新的VideoStruct結構體,重寫音頻解碼。但這不需要太大的改動,你可以看下面的代碼。我們最後還需要做的事是修改ffmpeg的內部"quit"回調函數。

VideoState *global_video_state;

int decode_interrupt_cb(void) {
  return (global_video_state && global_video_state->quit);
}
我們把global_video_state變量設置到main函數這個大的結構體中。
完成了,下面我們開始編譯它吧。

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

and enjoy your unsynced movie! Next time we'll finally build a video player that actually works!

享受這個音視頻不同步的電影吧!下回我們最終要設計一個真正可以正常工作的視頻播放器。

原文地址:http://dranger.com/ffmpeg/tutorial04.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章