背景說明
FFmpeg是一個開源,免費,跨平台的視訊和音頻流方案,它提供了一套完整的錄制、轉換以及流化音視訊的解決方案。而ffplay是有ffmpeg官方提供的一個基于ffmpeg的簡單點傳播放器。學習ffplay對于播放器流程、ffmpeg的調用等等是一個非常好的例子。本文就是對ffplay的一個基本的流程剖析,很多細節内容還需要繼續鑽研。
注:本文師基于ffmpeg-2.0版本進行分析,具體代碼行還請對号入座,謝謝!
主架構流程
下圖是一個使用“gcc+eygpt+graphviz+手工調整”生成的一個ffplay函數基本調用關系圖,其中隻保留了視訊部分,去除了音頻處理、字幕處理以及一些細節處理部分。
注:圖中的數字表示了播放中的一次基本調用流程,X?序号表示退出流程。
從上圖中我們可以了解到以下幾種資訊:
- 三個線程:主流程用于視訊圖像顯示和重新整理、read_thread用于讀取資料、video_thread用于解碼處理;
- 視訊資料處理:由read_thread讀取原始資料解複用後,按照packet的方式放入到隊列中;由video_thread從packet隊列中讀取packet解碼後,按照picture的方式放入到隊列中;由主流程從picture隊列中依次取picture進行顯示;
- 啟動流程:啟動流程如上圖中的數字部分
- 退出流程:退出流程如上圖中的X?序号部分
下面将對三個線程分别加以較長的描述。
read_thread線程
從read_thread開始說起而不是從main線程,主要原因是考慮按照視訊資料轉換的方式比較好了解。
read_thread的建立是在main-->stream_open函數中:
is->read_tid = SDL_CreateThread(read_thread, is); |
read_thread線程主要分為三部分:
- 初始化部分:主要包括SDL_mutex信号量建立、AVFormatContext建立、打開輸入檔案、解析碼流資訊、查找音視訊資料流并打開對應的資料流。對應ffplay.c檔案中的2693-2810行代碼;
- 循環讀取資料部分:主要包括pause和resume操作處理、seek操作處理、packet隊列寫入失敗處理、讀資料結束處理、然後是讀資料并寫入到對應的音視訊隊列中。對應ffplay.c檔案中的2812-2946行代碼;
- 反初始化部分:主要包括退出前的等待、關閉音視訊流、關閉avformat、給主線程發送FF_QUIT_EVENT消息以及銷毀SDL_mutex信号量。對應ffplay.c檔案中的2947-2972行代碼;
初始化部分
主要包括SDL_mutex信号量建立、建立avformat上下文、打開輸入檔案、解析碼流資訊、查找音視訊資料流并打開對應的資料流。
建立wait_mutex互斥量
SDL_mutex *wait_mutex = SDL_CreateMutex(); |
該互斥量主要用于在對(VideoState *)is->continue_read_thread操作時加保護,如2887行和2925行:
//代碼段一 if (infinite_buffer<1 && ……) { SDL_LockMutex(wait_mutex); SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); <-- line 2887 SDL_UnlockMutex(wait_mutex); continue; } //代碼段二 ret = av_read_frame(ic, pkt); if (ret < 0) { if (ret == AVERROR_EOF || url_feof(ic->pb)) eof = 1; if (ic->pb && ic->pb->error) break; SDL_LockMutex(wait_mutex); SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); <-- line 2925 SDL_UnlockMutex(wait_mutex); continue; } |
而continue_read_thread從其名字上來看,是一個控制read_thread線程是否繼續阻塞的信号量,上面兩次阻塞的地方分别是:packet隊列已滿,需要等待一會(即逾時10ms)或者收到信号重新循環;讀資料失敗,但是并不是IO錯誤(ic->pb->error),如讀取網絡實時資料時取不到資料,此時也需要等待或者收到信号重新循環。
注:seek操作時(L1216)和音頻隊列為空(L2327)時,會發送continue_read_thread信号。
AVFormatContext建立
(AVFormatContext *)ic = avformat_alloc_context(); |
此處建立的avformat上下文,類似于一個句柄,後續所有avformat相關的函數調用第一個參數都是該上下文指針,如avformat_open_input、avformat_find_stream_info以及一些和av相關的函數接口第一個參數也是該指針,如av_find_best_stream、av_read_frame等等。
打開輸入檔案
err = avformat_open_input(&ic, is->filename, is->iformat, &format_opts); |
建立好avformat上下文後,就打開is->filename指定的檔案(或流),其中第三個和第四個參數可以傳NULL,由ffmpeg自動偵測待輸入流的檔案格式,也可以通過is->iformat手動指定,format_opts參數表示設定的特殊屬性。
通過調用avformat_open_input函數,我們可以得到輸入流的一個基本資訊。我們可以通過調用av_dump_format(ic, 0, is->filename, 0);來輸出解析後的碼流資訊,可以得到如下資料:
Input #0, mpegts, from '/home/nfer/bak/cw880-latency.ts':0B f=0/0 Duration: N/A, bitrate: N/A Program 1 Stream #0:0[0x68]:Video:h264 ([27][0][0][0] / 0x001B), 90k tbn Stream #0:1[0x67]:Audio:aac([15][0][0][0] / 0x000F), 0 channels |
即,可以解析出
² 封裝格式是mpegts,包含兩路資料流
² 流1的PID是0x68,類型是視訊,編碼格式是H264
² 流2的PID是0x67,類型是音頻,編碼格式是AAC
但是隻有這些資訊可定無法解碼,比如視訊的寬高比、圖像編碼格式(YUV or RGB …)、音頻采樣率、音頻聲道數量等等,以及Duration、bitrate等資訊。這些資訊都需要通過其他函數來解析。
解析碼流資訊
err = avformat_find_stream_info(ic, opts); |
因為avformat_open_input函數隻能解析出一些基本的碼流資訊,不足以滿足解碼的要求,是以我們調用avformat_find_stream_info函數來盡量的解析出所有的和輸入流相關的資訊。
解析碼流的内部實作我們不在此處讨論,先看一看調用後該函數後解析出來的資訊(同樣采用av_dump_format來輸出):
Input #0, mpegts, from '/home/nfer/bak/cw880-latency.ts':0B f=0/0 Duration: 00:02:53.73, start: 2051.276989, bitrate: 1983 kb/s Program 1 Stream #0:0[0x68]: Video: h264 (Baseline) ([27][0][0][0] / 0x001B), yuv420p, 1280x720, 30 tbr, 90k tbn, 180k tbc Stream #0:1[0x67]: Audio: aac ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp,72 kb/s |
對比上一步擷取的資訊,我們可以看到新解析出來的資訊:
² 碼流資訊;節目時長00:02:53.73,開始播放時間2051.276989,碼率1983 kb/s
² 視訊資訊:色彩空間YUV420p,分辨率1280x720,幀率30,檔案層的時間精度90k,視訊層的時間精度180K
² 音頻資訊:采樣率48000,立體聲stereo,音頻采樣格式fltp(float, planar),音頻比特率72 kb/s
需要注意的是,該函數是一個阻塞操作,即預設情況下會在該函數中阻塞5s。具體的實作是在avformat_open_input函數中有一個for(;;) 循環,其中的一個break條件如下:
if (t >= ic->max_analyze_duration) { av_log(ic, AV_LOG_VERBOSE, "max_analyze_duration %d reached at %"PRId64" microseconds\n", ic->max_analyze_duration, t); break; } |
而ic->max_analyze_duration的預設值定義在options_table.h檔案中,即預設的參數表:
{"analyzeduration", "specify how many microseconds are analyzed to probe the input", OFFSET(max_analyze_duration), AV_OPT_TYPE_INT, {.i64 = 5*AV_TIME_BASE }, 0, INT_MAX, D}, #define AV_TIME_BASE 1000000 <--file: avutil.h, line: 229 |
如果覺得這個預設的5s阻塞時間太長,或者甚至覺得完全沒有必要,即我們可以手動的設定各種解碼的參數,那麼可以通過下面的方法将ic->max_analyze_duration的值修改為1s:
ic = avformat_alloc_context(); ic->interrupt_callback.callback = decode_interrupt_cb; ic->interrupt_callback.opaque = is; //add by Nfer ic->max_analyze_duration =1*1000*1000; av_log(NULL, AV_LOG_ERROR, "ic->max_analyze_duration %d.\n", ic->max_analyze_duration); err = avformat_open_input(&ic, is->filename, is->iformat, &ffp->format_opts); |
注:紅色部分為添加的代碼
查找音視訊資料流
if (!video_disable) st_index[AVMEDIA_TYPE_VIDEO] = av_find_best_stream(ic, AVMEDIA_TYPE_VIDEO, wanted_stream[AVMEDIA_TYPE_VIDEO], -1, NULL, 0); |
av_find_best_stream函數主要就做了一件事:找符合條件的資料流。其簡單實作可以參考ffmpeg-tutorial項目中tutorial01.c的代碼:
// 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 |
注:ffmpeg-tutorial項目是對Stephen Dranger寫的7個ffmpeg tutorial做的一個update。
打開對應的資料流
if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) { ret = stream_component_open(is, st_index[AVMEDIA_TYPE_VIDEO]); } |
通過最開始的主架構流程圖,我們可以大概的看到stream_component_open函數中最主要的動作就是調用packet_queue_start和建立video_thread線程。當然在這之前還有一些處理,其中包括:
查找解碼器
avctx = ic->streams[stream_index]->codec; codec = avcodec_find_decoder(avctx->codec_id); |
如果啟動ffplay時通過vcodec參數指定了解碼器名稱,那麼在通過codec_id查找到解碼器後,再使用forced_codec_name查找解碼avcodec_find_decoder_by_name。但是注意,如果通過解碼器名稱查找後會覆寫之前通過codec_id查找到解碼器,即如果在參數中指定了錯誤的解碼器會導緻無法正常播放的。
設定解碼參數
opts = filter_codec_opts(codec_opts, avctx->codec_id, ic, ic->streams[stream_index], codec); if (!av_dict_get(opts, "threads", NULL, 0)) av_dict_set(&opts, "threads", "auto", 0); if (avctx->lowres) av_dict_set(&opts, "lowres", av_asprintf("%d", avctx->lowres), AV_DICT_DONT_STRDUP_VAL); if (avctx->codec_type == AVMEDIA_TYPE_VIDEO || avctx->codec_type == AVMEDIA_TYPE_AUDIO) av_dict_set(&opts, "refcounted_frames", "1", 0); |
打開解碼器
if (avcodec_open2(avctx, codec, &opts) < 0) return -1; |
啟動packet隊列
packet_queue_start(&is->videoq); |
啟動packet隊列時,會向隊列中先放置一個flush_pkt,其中詳細緣由後面再講。
建立video_thread線程
is->video_stream = stream_index; is->video_st = ic->streams[stream_index]; is->video_tid = SDL_CreateThread(video_thread, is); is->queue_attachments_req = 1; |
注:上述分析過程中沒有考慮音頻和字幕處理的部分,後續有機會再詳解。
循環讀取資料部分
該部分是一個for (;;)循環,循環中主要包括pause和resume操作處理、seek操作處理、packet隊列寫入失敗處理、讀資料結束處理、然後是讀資料并寫入到對應的音視訊隊列中。
for循環跳出條件
有兩處是break處理的:
//代碼段一 if (is->abort_request) break; <-- Line 2814 //代碼段二 ret = av_read_frame(ic, pkt); if (ret < 0) { if (ic->pb && ic->pb->error) break; <-- Line 2923 } |
其中條件一是調用do_exit --> stream_close中将is->abort_request置為1的,代碼中有多個地方是判斷該條件進行exit處理的;條件二很清晰,就是當遇到讀資料失敗并且是IO錯誤時,會退出。
pause和resume操作處理
if (is->paused != is->last_paused) { is->last_paused = is->paused; if (is->paused) is->read_pause_return = av_read_pause(ic); else av_read_play(ic); } |
在ffplay中暫停和恢複的按鍵操作時p鍵(SDLK_p)和space鍵(SDLK_SPACE),會調用toggle_pause--> stream_toggle_pause來修改is->paused标記變量,然後在read_thread線程中通過對is->paused标記變量的判斷進行pause和resum(play)的處理。
seek操作處理
if (is->seek_req) { ret = avformat_seek_file(is->ic, -1, seek_min, seek_target, seek_max, is->seek_flags); if (is->video_stream >= 0) { packet_queue_flush(&is->videoq); packet_queue_put(&is->videoq, &flush_pkt); } is->seek_req = 0; } |
注:上述代碼有所删減,隻保留了和視訊相關的部分
同上面pause和resume的處理,is->seek_req是在按鍵操作(SDLK_PAGEUP、SDLK_PAGEDOWN、SDLK_LEFT、SDLK_RIGHT、SDLK_UP和SDLK_DOWN)時,調用stream_seek函數來修改is->seek_req标記變量,然後在read_thread線程中根據is->seek_req标記變量來進行處理。
具體處理除了調用ffmpeg的avformat_seek_file接口外,還向packet隊列中放置了一個flush_pkt,這個在video_thread中的進行中會解決seek操作的花屏效果。
packet隊列寫入失敗處理
if (infinite_buffer<1 && (is->audioq.size + is->videoq.size + is->subtitleq.size > MAX_QUEUE_SIZE || ( (is->audioq .nb_packets > MIN_FRAMES || is->audio_stream < 0 || is->audioq.abort_request) && (is->videoq .nb_packets > MIN_FRAMES || is->video_stream < 0 || is->videoq.abort_request || (is->video_st->disposition & AV_DISPOSITION_ATTACHED_PIC)) && (is->subtitleq.nb_packets > MIN_FRAMES || is->subtitle_stream < 0 || is->subtitleq.abort_request)))) { SDL_LockMutex(wait_mutex); SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10); SDL_UnlockMutex(wait_mutex); continue; } |
此處的各種判斷條件不詳細解釋,重點是在播放器進行中,寫資料失敗時需要wait and continue的處理。
讀資料結束處理
if (eof) { if (is->video_stream >= 0) { av_init_packet(pkt); pkt->data = NULL; pkt->size = 0; pkt->stream_index = is->video_stream; packet_queue_put(&is->videoq, pkt); } SDL_Delay(10); if (is->audioq.size + is->videoq.size + is->subtitleq.size == 0) { if (loop != 1 && (!loop || --loop)) { stream_seek(is, start_time != AV_NOPTS_VALUE ? start_time : 0, 0, 0); } else if (autoexit) { ret = AVERROR_EOF; goto fail; } } eof=0; continue; } |
當遇到eof,即end of file時,做一下幾個步驟:
- 向packet隊列中放置一個null packet,此處用于loop時使用
- 判斷是否是loop操作,如果是就seek到開始位置重新播放
- 如果是autoexit模式,就goto fail退出
注意,在讀資料eof時,讀資料部分還有些滞後,即if (is->audioq.size + is->videoq.size + is->subtitleq.size== 0)判斷不一定為true,引起在判斷前先delay了10ms(SDL_Delay(10););但是仍然不一定為true,是以需要continue。當然下一步av_read_frame失敗也會傳回AVERROR_EOF,eof會重新指派為1。即,eof退出會wait到真正的播放完畢。
讀資料并寫入到對應的音視訊隊列
ret = av_read_frame(ic, pkt); if (pkt->stream_index == is->video_stream && pkt_in_play_range && !(is->video_st->disposition & AV_DISPOSITION_ATTACHED_PIC)) { packet_queue_put(&is->videoq, pkt); } |
注:上述代碼有所删減,隻保留了和視訊相關的部分
此處的處理實際上比較簡單,就是av_read_frame和packet_queue_put,不詳解。
反初始化部分
主要包括退出前的等待、關閉音視訊流、關閉avformat、給主線程發送FF_QUIT_EVENT消息以及銷毀SDL_mutex信号量。
退出前的等待
while (!is->abort_request) { SDL_Delay(100); } |
因為之前for循環跳出條件中說明了隻有兩種情況下才會break出來,其一就是is->abort_request為true,其二直接就goto到fail了,是以兩種情況下該while循環都不會判斷為true,直接略過。具體代碼原因不明。
關閉音視訊流
if (is->video_stream >= 0) stream_component_close(is, is->video_stream); |
注:上述代碼有所删減,隻保留了和視訊相關的部分
其中stream_component_close關閉視訊流做了以下處理:
- 終止packet隊列:packet_queue_abort(&is->videoq);
- 發送信号給video_thread,避免繼續解碼阻塞:SDL_CondSignal(is->pictq_cond);
- 等待vide_thread線程退出:SDL_WaitThread(is->video_tid, NULL);
- 清空packet隊列:packet_queue_flush(&is->videoq);
給主線程發送FF_QUIT_EVENT
if (ret != 0) { SDL_Event event; event.type = FF_QUIT_EVENT; event.user.data1 = is; SDL_PushEvent(&event); } |
在主線程會接收到FF_QUIT_EVENT消息,進而會調用do_exit函數來做退出處理。
銷毀SDL_mutex信号量
SDL_DestroyMutex(wait_mutex); |
read_thread基本就分析到這裡,下面描述以下video_thread。
video_thread線程
從主架構流程中可以看出,video_thread線程是在read_thread--> stream_component_open中建立的,負責從packet隊列中讀取packet并解碼為picture,然後存儲到picture隊列中供主線程讀取并重新整理顯示。
video_thread的建立是在read_thread --> stream_component_open函數中:
is->video_tid = SDL_CreateThread(video_thread, is); |
read_thread線程同樣分為三部分:
- 初始化部分:主要包括AVFrame建立和AVFilterGraph建立。對應ffplay.c檔案中的1881-1895行代碼;
- 循環解碼部分:主要包括pause和resume操作處理、讀取packet處理、AVFILTER處理、然後是将picture寫入視訊隊列中以及每次解碼後的清理動作。對應ffplay.c檔案中的1897-1966行代碼;
- 反初始化部分:主要包括重新整理codec中的資料、釋放AVFilterGraph、釋放AVPacket以及釋放AVFrame。對應ffplay.c檔案中的1972-1978行代碼;
初始化部分
該線程的初始化就是建立了AVFrame和AVFilterGraph,其中AVFilterGraph還是和編譯宏包含,如果沒有打開CONFIG_AVFILTER可以直接省略。
is->video_tid = SDL_CreateThread(video_thread, is); … … AVFrame *frame = av_frame_alloc(); #if CONFIG_AVFILTER AVFilterGraph *graph = avfilter_graph_alloc(); #endif |
循環解碼部分
主要包括pause和resume操作處理、讀取packet處理、AVFILTER處理、然後是将picture寫入視訊隊列中以及每次解碼後的清理動作。
pause和resume操作處理
video_thread中的關于pause和resume的處理比較簡單,就是如果是pause狀态就delay(線程sleep):
while (is->paused && !is->videoq.abort_request) SDL_Delay(10); |
讀取packet處理
avcodec_get_frame_defaults(frame); av_free_packet(&pkt); ret = get_video_frame(is, frame, &pkt, &serial); //關于frame的一些處理 av_frame_unref(frame); |
從上述代碼中可以看出,一個frame(和packet)的完整生命流程。
在ffmpeg-tutorial項目中tutorial01.c中的例子是使用avcodec_alloc_frame()來申請并設定default value的操作,但是在這裡就分成了兩步:av_frame_alloc()然後avcodec_get_frame_defaults(frame)。
av_free_packet實際上清空上一次get_video_frame中擷取的packet資料,函數本身是有異常處理的,是以連續調用兩次av_free_packet是沒有問題的。
get_video_frame函數中主要部分是packet_queue_get然後avcodec_decode_video2,即從packet隊列中讀取資料然後進行解碼,具體内容有機會另開文章進行講解。
AVFILTER處理
AVFILTER處理是一個比較子產品化很高的處理部分,大緻流程包括以下幾步:
- 釋放舊的AVFilterGraph并建立一個新的:avfilter_graph_free()和avfilter_graph_alloc()
- 配置video filters:configure_video_filters
- 向buffersrc中添加frame:av_buffersrc_add_frame
- 情況原有的frame和packet:av_frame_unref、avcodec_get_frame_defaults和av_free_packet
- 從buffersink中讀取處理後的frame:av_buffersink_get_frame_flags
簡單的了解就是:
将picture寫入視訊隊列
如果需要avfilter處理,那麼處理完後或者不需要avfilter處理,解碼完成後的frame會調用queue_picture寫入到picture隊列中。具體細節不詳解。
解碼後的清理動作
使用完packet後,必須從frame中釋放出來:av_frame_unref。如api說明:Unreference allthe buffers referenced by frame and reset the frame fields.
for循環跳出條件
有以下幾種情況下會break出for循環:
- get_video_frame讀資料失敗,并且傳回<0:該函數失敗條件和read_thread其實是一緻的,即當q->abort_request為true時;
- configure_video_filters配置filter失敗:該函數失敗的情況下,我遇到的一種就是avfilter_graph_create_filter建立crop filter時失敗,原因在于在configureffmpeg時沒有把filter配置打開,導緻隻有預設的幾個filter,其他一些特性filter都沒有添加進行;
- av_buffersrc_add_frame添加frame失敗:該函數屬于api,不詳解;
- queue_picture儲存picture失敗:該函數的失敗條件是當is->videoq.abort_request為true時;
即正常情況下,有兩種退出模式:
- 正常播放完成後退出,此時會通過get_video_frame讀資料失敗退出
- 如果是按ESCAPE和Q鍵退出,會直接退出,則不會等到,直接在queue_picture函數失敗
反初始化部分
反初始化部分比較簡單,就是先通知avcodec進行flush資料,然後依次釋放AVFilterGraph、AVPacket和AVFrame。
video_thread講解的比較粗糙,主要原因還是由于個人了解的知識有所欠缺,後續有機會會補上。
主線程
主流程用于視訊圖像顯示和重新整理,實際上還主線程是一個事件驅動的,就是一個wait_event然後switch處理,然後繼續for循環。
refresh_loop_wait_event處理
該函數會從event隊列中讀取出event,SDL_PumpEvents、SDL_PeepEvents。同時會調用video_refresh來進行視訊重新整理和顯示。此處會有大量和SDL API相關的操作,由于個人能力有限暫不分析。
event的switch處理
該event的處理分為以下幾類:
- SDL_KEYDOWN鍵盤按鍵事件
- SDL_VIDEOEXPOSE螢幕重畫事件
- SDL_MOUSEBUTTONDOWN滑鼠按下事件,如果啟動ffplay時有exitonmousedown參數,會相應滑鼠按下事件,然後退出播放;
- SDL_MOUSEMOTION滑鼠移動事件,主要seek操作
- SDL_VIDEORESIZE視訊大小變化事件,比如視訊中間會出現大小變化,會觸發該事件
- SDL_QUIT、FF_QUIT_EVENT退出事件,如read_thread中出現各種異常會發送該消息
- FF_ALLOC_EVENT事件比較特殊,如代碼中的注釋“ifthe queue is aborted, we have to pop the pending ALLOC event or wait for theallocation to complete”,該消息是video_thread中的發出的消息
總結
由于時間有限,文章有些虎頭蛇尾,還請各位諒解。有多個方面沒有詳細分析,如音頻處理和字幕處理部分,音視訊同步,SDL顯示等等很多很多有關的知識,這些知識對于我來說大部分也還是全新的東西,後續有機會還會繼續學習和各位分享。