本文轉自:FFmpeg 入門(4):線程分治 | www.samirchen.com
概覽
上一節教程中,我們使用 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 線程,這樣我們可以准確的控制下一個視頻幀什么時候顯示到屏幕上。在下一節教程中當我們要做音視頻同步時,相關的邏輯就會變得簡單些了。
精簡代碼
我們現在要創建一個比較大的結構體來容納所有音視頻信息,叫做 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;視頻和音頻流的標記 videoStream、audioStream,以及對應的視頻和音頻流對象 audio_st、video_st;接下來,是我們移過來的音頻緩沖區相關的數據:audio_buf、audio_buf_size、audio_buf_index 等;我們還添加了視頻數據隊列 videoq、視頻數據緩沖區 pictq 來存儲解碼后的視頻幀,VideoPicture 是我們自己創建的數據結構,我們后面再看里面都有些啥;我們還增加了兩個指向對應線程的指針:parse_tid、video_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 提供的一個字符串拷貝方法,增加了一些邊界檢查功能。
第一個線程
現在我們啟動 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() 傳入的參數是前面初始化的 VideoState。decode_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_st 和 video_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。
獲取幀: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->codec 從 VideoState 中獲取視頻的 codec。我們持續從 video queue 中獲取 packet 數據包,直到有人告訴我們 quit 或者遇到錯誤。
幀隊列
接着,我們看一下存儲解碼幀的函數 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。然后讀數據方會知道隊列中有數據了,如果隊列滿了,我們寫數據方也會知道。
顯示視頻
上面介紹完了 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);
}
}
由於我們的屏幕可以是任意尺寸(我們自己設置的是 640x480,但是這個對用戶應該是可以改變的),所以我們需要能夠動態地計算我們要顯示圖像的尺寸。首先,我們需要計算出視頻的 aspect ratio,即寬度和高度的比例(width/height)。但是有一些 codec 有很奇怪的 sample aspect ration,即單像素(單采樣)的寬高比(width/height),又由於我們的 AVCodecContext 中的寬度和高度是以像素為單位來表示的,那么這時候 actual aspect ratio 應該是 aspect ratio 乘上 sample aspect ratio。有的 codec 的 aspect ratio 值是 0,這表示的是每個像素的尺寸是 1x1。
接下來,我們放大視頻來盡量適配我們的屏幕。代碼中的 & -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_state 為 VideoState *is。
以上便是我們這節教程的全部內容,其中的完整代碼你可以從這里獲得:https://github.com/samirchen/TestFFmpeg
編譯執行
你可以使用下面的命令編譯它:
$ gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lswscale -lz -lm `sdl-config --cflags --libs`
找一個視頻文件,你可以這樣執行一下試試:
$ tutorial04 myvideofile.mp4
