作者: 葉餘 來源: https://www.cnblogs.com/leisure_chn/p/10301831.html ffplay是FFmpeg工程自帶的簡單點傳播放器,使用FFmpeg提供的解碼器和SDL庫進行視訊播放。本文基于FFmpeg工程4.1版本進行分析,其中ffplay源碼清單如下: https://github.com/FFmpeg/FFmpeg/blob/n4.1/fftools/ffplay.c
在嘗試分析源碼前,可先閱讀如下參考文章作為鋪墊:
[1].
雷霄骅,視音頻編解碼技術零基礎學習方法 [2]. 視訊編解碼基礎概念 [3]. 色彩空間與像素格式 [4]. 音頻參數解析 [5]. FFmpeg基礎概念 “ffplay源碼分析”系列文章如下: ffplay源碼分析1-概述 ffplay源碼分析2-資料結構 ffplay源碼分析3-代碼架構 ffplay源碼分析4-音視訊同步 ffplay源碼分析5-圖像格式轉換 [6]. ffplay源碼分析6-音頻重采樣 [7]. ffplay源碼分析7-播放控制3. 代碼架構
本節簡單梳理ffplay.c代碼架構。一些關鍵問題及細節問題在後續章節探讨。
3.1 流程圖

3.2 主線程
主線程主要實作三項功能:視訊播放(音視訊同步)、字幕播放、SDL消息處理。
主線程在進行一些必要的初始化工作、建立解複用線程後,即進入event_loop()主循環,處理視訊播放和SDL消息事件:
main() -->
static void event_loop(VideoState *cur_stream)
{
SDL_Event event;
......
for (;;) {
// SDL event隊列為空,則在while循環中播放視訊幀。否則從隊列頭部取一個event,退出目前函數,在上級函數中處理event
refresh_loop_wait_event(cur_stream, &event);
// SDL事件處理
switch (event.type) {
case SDL_KEYDOWN:
switch (event.key.keysym.sym) {
case SDLK_f: // f鍵:強制重新整理
......
break;
case SDLK_p: // p鍵
case SDLK_SPACE: // 空格鍵:暫停
......
case SDLK_s: // s鍵:逐幀播放
......
break;
......
......
}
}
}
3.2.1 視訊播放
主要代碼在refresh_loop_wait_event()函數中,如下:
static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
double remaining_time = 0.0;
SDL_PumpEvents();
while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) {
if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {
SDL_ShowCursor(0);
cursor_hidden = 1;
}
if (remaining_time > 0.0)
av_usleep((int64_t)(remaining_time * 1000000.0));
remaining_time = REFRESH_RATE;
if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
// 立即顯示目前幀,或延時remaining_time後再顯示
video_refresh(is, &remaining_time);
SDL_PumpEvents();
}
}
while()
語句表示如果SDL event隊列為空,則在while循環中播放視訊幀;否則從隊列頭部取一個event,退出目前函數,在上級函數中處理event。
refresh_loop_wait_event()
中調用了非常關鍵的函數
video_refresh()
,
video_refresh()
函數實作音視訊的同步及視訊幀的顯示,是ffplay.c中最核心函數之一,在“4.3節 視訊同步到音頻”中詳細分析。
3.2.2 SDL消息處理
處理各種SDL消息,比如暫停、強制重新整理等按鍵事件。比較簡單。
main() -->
static void event_loop(VideoState *cur_stream)
{
SDL_Event event;
......
for (;;) {
// SDL event隊列為空,則在while循環中播放視訊幀。否則從隊列頭部取一個event,退出目前函數,在上級函數中處理event
refresh_loop_wait_event(cur_stream, &event);
// SDL事件處理
switch (event.type) {
case SDL_KEYDOWN:
switch (event.key.keysym.sym) {
case SDLK_f: // f鍵:強制重新整理
......
break;
case SDLK_p: // p鍵
case SDLK_SPACE: // 空格鍵:暫停
......
break;
......
......
}
}
}
3.3 解複用線程
解複用線程讀取視訊檔案,将取到的packet根據類型(音頻、視訊、字幕)存入不同是packet隊列中。
為節省篇幅,如下源碼中非關鍵内容的源碼使用“......”替代。代碼流程參考注釋。
/* this thread gets the stream from the disk or the network */
static int read_thread(void *arg)
{
VideoState *is = arg;
AVFormatContext *ic = NULL;
int st_index[AVMEDIA_TYPE_NB];
......
......
// 中斷回調機制。為底層I/O層提供一個處理接口,比如中止IO操作。
ic->interrupt_callback.callback = decode_interrupt_cb;
ic->interrupt_callback.opaque = is;
if (!av_dict_get(format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_CASE)) {
av_dict_set(&format_opts, "scan_all_pmts", "1", AV_DICT_DONT_OVERWRITE);
scan_all_pmts_set = 1;
}
// 1. 建構AVFormatContext
// 1.1 打開視訊檔案:讀取檔案頭,将檔案格式資訊存儲在"fmt context"中
err = avformat_open_input(&ic, is->filename, is->iformat, &format_opts);
......
if (find_stream_info) {
......
// 1.2 搜尋流資訊:讀取一段視訊檔案資料,嘗試解碼,将取到的流資訊填入ic->streams
// ic->streams是一個指針數組,數組大小是ic->nb_streams
err = avformat_find_stream_info(ic, opts);
......
}
......
// 2. 查找用于解碼處理的流
// 2.1 将對應的stream_index存入st_index[]數組
if (!video_disable)
st_index[AVMEDIA_TYPE_VIDEO] = // 視訊流
av_find_best_stream(ic, AVMEDIA_TYPE_VIDEO,
st_index[AVMEDIA_TYPE_VIDEO], -1, NULL, 0);
if (!audio_disable)
st_index[AVMEDIA_TYPE_AUDIO] = // 音頻流
av_find_best_stream(ic, AVMEDIA_TYPE_AUDIO,
st_index[AVMEDIA_TYPE_AUDIO],
st_index[AVMEDIA_TYPE_VIDEO],
NULL, 0);
if (!video_disable && !subtitle_disable)
st_index[AVMEDIA_TYPE_SUBTITLE] = // 字幕流
av_find_best_stream(ic, AVMEDIA_TYPE_SUBTITLE,
st_index[AVMEDIA_TYPE_SUBTITLE],
(st_index[AVMEDIA_TYPE_AUDIO] >= 0 ?
st_index[AVMEDIA_TYPE_AUDIO] :
st_index[AVMEDIA_TYPE_VIDEO]),
NULL, 0);
is->show_mode = show_mode;
// 2.2 從待處理流中擷取相關參數,設定顯示視窗的寬度、高度及寬高比
if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) {
AVStream *st = ic->streams[st_index[AVMEDIA_TYPE_VIDEO]];
AVCodecParameters *codecpar = st->codecpar;
// 根據流和幀寬高比猜測幀的樣本寬高比。
// 由于幀寬高比由解碼器設定,但流寬高比由解複用器設定,是以這兩者可能不相等。此函數會嘗試傳回待顯示幀應當使用的寬高比值。
// 基本邏輯是優先使用流寬高比(前提是值是合理的),其次使用幀寬高比。這樣,流寬高比(容器設定,易于修改)可以覆寫幀寬高比。
AVRational sar = av_guess_sample_aspect_ratio(ic, st, NULL);
if (codecpar->width)
// 設定顯示視窗的大小和寬高比
set_default_window_size(codecpar->width, codecpar->height, sar);
}
// 3. 建立對應流的解碼線程
/* open the streams */
if (st_index[AVMEDIA_TYPE_AUDIO] >= 0) {
// 3.1 建立音頻解碼線程
stream_component_open(is, st_index[AVMEDIA_TYPE_AUDIO]);
}
ret = -1;
if (st_index[AVMEDIA_TYPE_VIDEO] >= 0) {
// 3.2 建立視訊解碼線程
ret = stream_component_open(is, st_index[AVMEDIA_TYPE_VIDEO]);
}
if (is->show_mode == SHOW_MODE_NONE)
is->show_mode = ret >= 0 ? SHOW_MODE_VIDEO : SHOW_MODE_RDFT;
if (st_index[AVMEDIA_TYPE_SUBTITLE] >= 0) {
// 3.3 建立字幕解碼線程
stream_component_open(is, st_index[AVMEDIA_TYPE_SUBTITLE]);
}
......
// 4. 解複用處理
for (;;) {
// 停止
......
// 暫停/繼續
......
// seek操作
......
......
// 4.1 從輸入檔案中讀取一個packet
ret = av_read_frame(ic, pkt);
if (ret < 0) {
if ((ret == AVERROR_EOF || avio_feof(ic->pb)) && !is->eof) {
// 輸入檔案已讀完,則往packet隊列中發送NULL packet,以沖洗(flush)解碼器,否則解碼器中緩存的幀取不出來
if (is->video_stream >= 0)
packet_queue_put_nullpacket(&is->videoq, is->video_stream);
if (is->audio_stream >= 0)
packet_queue_put_nullpacket(&is->audioq, is->audio_stream);
if (is->subtitle_stream >= 0)
packet_queue_put_nullpacket(&is->subtitleq, is->subtitle_stream);
is->eof = 1;
}
if (ic->pb && ic->pb->error) // 出錯則退出目前線程
break;
SDL_LockMutex(wait_mutex);
SDL_CondWaitTimeout(is->continue_read_thread, wait_mutex, 10);
SDL_UnlockMutex(wait_mutex);
continue;
} else {
is->eof = 0;
}
// 4.2 判斷目前packet是否在播放範圍内,是則入列,否則丢棄
/* check if packet is in play range specified by user, then queue, otherwise discard */
stream_start_time = ic->streams[pkt->stream_index]->start_time; // 第一個顯示幀的pts
pkt_ts = pkt->pts == AV_NOPTS_VALUE ? pkt->dts : pkt->pts;
// 簡化一下"||"後那個長長的表達式:
// [pkt_pts] - [stream_start_time] - [start_time] <= [duration]
// [目前幀pts] - [第一幀pts] - [目前播放序列第一幀(seek起始點)pts] <= [duration]
pkt_in_play_range = duration == AV_NOPTS_VALUE ||
(pkt_ts - (stream_start_time != AV_NOPTS_VALUE ? stream_start_time : 0)) *
av_q2d(ic->streams[pkt->stream_index]->time_base) -
(double)(start_time != AV_NOPTS_VALUE ? start_time : 0) / 1000000
<= ((double)duration / 1000000);
// 4.3 根據目前packet類型(音頻、視訊、字幕),将其存入對應的packet隊列
if (pkt->stream_index == is->audio_stream && pkt_in_play_range) {
packet_queue_put(&is->audioq, pkt);
} else 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);
} else if (pkt->stream_index == is->subtitle_stream && pkt_in_play_range) {
packet_queue_put(&is->subtitleq, pkt);
} else {
av_packet_unref(pkt);
}
}
ret = 0;
fail:
......
return 0;
}
解複用線程實作如下功能:
[1]. 建立音頻、視訊、字幕解碼線程
[2]. 從輸入檔案讀取packet,根據packet類型(音頻、視訊、字幕)将這放入不同packet隊列
3.4 視訊解碼線程
視訊解碼線程從視訊packet隊列中取資料,解碼後存入視訊frame隊列。
3.4.1 video_thread()
視訊解碼線程将解碼後的幀放入frame隊列中。為節省篇幅,如下源碼中删除了濾鏡filter相關代碼。
// 視訊解碼線程:從視訊packet_queue中取資料,解碼後放入視訊frame_queue
static int video_thread(void *arg)
{
VideoState *is = arg;
AVFrame *frame = av_frame_alloc();
double pts;
double duration;
int ret;
AVRational tb = is->video_st->time_base;
AVRational frame_rate = av_guess_frame_rate(is->ic, is->video_st, NULL);
if (!frame) {
return AVERROR(ENOMEM);
}
for (;;) {
ret = get_video_frame(is, frame);
if (ret < 0)
goto the_end;
if (!ret)
continue;
// 目前幀播放時長
duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
// 目前幀顯示時間戳
pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb);
// 将目前幀壓入frame_queue
ret = queue_picture(is, frame, pts, duration, frame->pkt_pos, is->viddec.pkt_serial);
av_frame_unref(frame);
if (ret < 0)
goto the_end;
}
the_end:
av_frame_free(&frame);
return 0;
}
3.4.2 get_video_frame()
從packet隊列中取一個packet解碼得到一個frame,并判斷是否要根據framedrop機制丢棄失去同步的視訊幀。參考源碼中注釋:
static int get_video_frame(VideoState *is, AVFrame *frame)
{
int got_picture;
if ((got_picture = decoder_decode_frame(&is->viddec, frame, NULL)) < 0)
return -1;
if (got_picture) {
double dpts = NAN;
if (frame->pts != AV_NOPTS_VALUE)
dpts = av_q2d(is->video_st->time_base) * frame->pts;
frame->sample_aspect_ratio = av_guess_sample_aspect_ratio(is->ic, is->video_st, frame);
// ffplay文檔中對"-framedrop"選項的說明:
// Drop video frames if video is out of sync.Enabled by default if the master clock is not set to video.
// Use this option to enable frame dropping for all master clock sources, use - noframedrop to disable it.
// "-framedrop"選項用于設定當視訊幀失去同步時,是否丢棄視訊幀。"-framedrop"選項以bool方式改變變量framedrop值。
// 音視訊同步方式有三種:A同步到視訊,B同步到音頻,C同步到外部時鐘。
// 1) 當指令行不帶"-framedrop"選項或"-noframedrop"時,framedrop值為預設值-1,若同步方式是"同步到視訊"
// 則不丢棄失去同步的視訊幀,否則将丢棄失去同步的視訊幀。
// 2) 當指令行帶"-framedrop"選項時,framedrop值為1,無論何種同步方式,均丢棄失去同步的視訊幀。
// 3) 當指令行帶"-noframedrop"選項時,framedrop值為0,無論何種同步方式,均不丢棄失去同步的視訊幀。
if (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) {
if (frame->pts != AV_NOPTS_VALUE) {
double diff = dpts - get_master_clock(is);
if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD &&
diff - is->frame_last_filter_delay < 0 &&
is->viddec.pkt_serial == is->vidclk.serial &&
is->videoq.nb_packets) {
is->frame_drops_early++;
av_frame_unref(frame); // 視訊幀失去同步則直接扔掉
got_picture = 0;
}
}
}
}
return got_picture;
}
ffplay中framedrop處理有兩種,一處是此處解碼後得到的frame尚未存入frame隊列前,以is->frame_drops_early++為标記;另一處是frame隊列中讀取frame進行顯示的時候,以is->frame_drops_late++為标記。
本處framedrop操作涉及的變量is->frame_last_filter_delay屬于濾鏡filter操作相關,ffplay中預設是關閉濾鏡的,本文不考慮濾鏡相關操作。
3.4.3 decoder_decode_frame()
這個函數是很核心的一個函數,可以解碼視訊幀和音頻幀。視訊解碼線程中,視訊幀實際的解碼操作就在此函數中進行。分析過程參考3.2節。
3.5 音頻解碼線程
音頻解碼線程從音頻packet隊列中取資料,解碼後存入音頻frame隊列
3.5.1 打開音頻裝置
音頻裝置的打開實際是在解複用線程中實作的。解複用線程中先打開音頻裝置(設定音頻回調函數供SDL音頻播放線程回調),然後再建立音頻解碼線程。調用鍊如下:
main() -->
stream_open() -->
read_thread() -->
stream_component_open() -->
audio_open(is, channel_layout, nb_channels, sample_rate, &is->audio_tgt);
decoder_start(&is->auddec, audio_thread, is);
audio_open()函數填入期望的音頻參數,打開音頻裝置後,将實際的音頻參數存入輸出參數is->audio_tgt中,後面音頻播放線程用會用到此參數。
音頻格式的各參數與重采樣強相關,audio_open()的詳細實作在後面第5節講述。
3.5.2 audio_thread()
從音頻packet_queue中取資料,解碼後放入音頻frame_queue:
// 音頻解碼線程:從音頻packet_queue中取資料,解碼後放入音頻frame_queue
static int audio_thread(void *arg)
{
VideoState *is = arg;
AVFrame *frame = av_frame_alloc();
Frame *af;
int got_frame = 0;
AVRational tb;
int ret = 0;
if (!frame)
return AVERROR(ENOMEM);
do {
if ((got_frame = decoder_decode_frame(&is->auddec, frame, NULL)) < 0)
goto the_end;
if (got_frame) {
tb = (AVRational){1, frame->sample_rate};
if (!(af = frame_queue_peek_writable(&is->sampq)))
goto the_end;
af->pts = (frame->pts == AV_NOPTS_VALUE) ? NAN : frame->pts * av_q2d(tb);
af->pos = frame->pkt_pos;
af->serial = is->auddec.pkt_serial;
// 目前幀包含的(單個聲道)采樣數/采樣率就是目前幀的播放時長
af->duration = av_q2d((AVRational){frame->nb_samples, frame->sample_rate});
// 将frame資料拷入af->frame,af->frame指向音頻frame隊列尾部
av_frame_move_ref(af->frame, frame);
// 更新音頻frame隊列大小及寫指針
frame_queue_push(&is->sampq);
}
} while (ret >= 0 || ret == AVERROR(EAGAIN) || ret == AVERROR_EOF);
the_end:
av_frame_free(&frame);
return ret;
}
3.5.3 decoder_decode_frame()
此函數既可以解碼音頻幀,也可以解碼視訊幀,函數分析參考3.2節。
3.6 音頻播放線程
音頻播放線程是SDL内建的線程,通過回調的方式調用使用者提供的回調函數。
回調函數在SDL_OpenAudio()時指定。
暫停/繼續回調過程由SDL_PauseAudio()控制。
3.6.1 sdl_audio_callback()
音頻回調函數如下:
// 音頻處理回調函數。讀隊列擷取音頻包,解碼,播放
// 此函數被SDL按需調用,此函數不在使用者主線程中,是以資料需要保護
// \param[in] opaque 使用者在注冊回調函數時指定的參數
// \param[out] stream 音頻資料緩沖區位址,将解碼後的音頻資料填入此緩沖區
// \param[out] len 音頻資料緩沖區大小,機關位元組
// 回調函數傳回後,stream指向的音頻緩沖區将變為無效
// 雙聲道采樣點的順序為LRLRLR
/* prepare a new audio buffer */
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{
VideoState *is = opaque;
int audio_size, len1;
audio_callback_time = av_gettime_relative();
while (len > 0) { // 輸入參數len等于is->audio_hw_buf_size,是audio_open()中申請到的SDL音頻緩沖區大小
if (is->audio_buf_index >= is->audio_buf_size) {
// 1. 從音頻frame隊列中取出一個frame,轉換為音頻裝置支援的格式,傳回值是重采樣音頻幀的大小
audio_size = audio_decode_frame(is);
if (audio_size < 0) {
/* if error, just output silence */
is->audio_buf = NULL;
is->audio_buf_size = SDL_AUDIO_MIN_BUFFER_SIZE / is->audio_tgt.frame_size * is->audio_tgt.frame_size;
} else {
if (is->show_mode != SHOW_MODE_VIDEO)
update_sample_display(is, (int16_t *)is->audio_buf, audio_size);
is->audio_buf_size = audio_size;
}
is->audio_buf_index = 0;
}
// 引入is->audio_buf_index的作用:防止一幀音頻資料大小超過SDL音頻緩沖區大小,這樣一幀資料需要經過多次拷貝
// 用is->audio_buf_index辨別重采樣幀中已拷入SDL音頻緩沖區的資料位置索引,len1表示本次拷貝的資料量
len1 = is->audio_buf_size - is->audio_buf_index;
if (len1 > len)
len1 = len;
// 2. 将轉換後的音頻資料拷貝到音頻緩沖區stream中,之後的播放就是音頻裝置驅動程式的工作了
if (!is->muted && is->audio_buf && is->audio_volume == SDL_MIX_MAXVOLUME)
memcpy(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, len1);
else {
memset(stream, 0, len1);
if (!is->muted && is->audio_buf)
SDL_MixAudioFormat(stream, (uint8_t *)is->audio_buf + is->audio_buf_index, AUDIO_S16SYS, len1, is->audio_volume);
}
len -= len1;
stream += len1;
is->audio_buf_index += len1;
}
// is->audio_write_buf_size是本幀中尚未拷入SDL音頻緩沖區的資料量
is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;
/* Let's assume the audio driver that is used by SDL has two periods. */
// 3. 更新時鐘
if (!isnan(is->audio_clock)) {
// 更新音頻時鐘,更新時刻:每次往聲霸卡緩沖區拷入資料後
// 前面audio_decode_frame中更新的is->audio_clock是以音頻幀為機關,是以此處第二個參數要減去未拷貝資料量占用的時間
set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);
// 使用音頻時鐘更新外部時鐘
sync_clock_to_slave(&is->extclk, &is->audclk);
}
}
3.6.2 audio_decode_frame()
audio_decode_frame()
主要是進行音頻重采樣,從音頻frame隊列中取出一個frame,此frame的格式是輸入檔案中的音頻格式,音頻裝置不一定支援這些參數,是以要将frame轉換為音頻裝置支援的格式。
audio_decode_frame()
的實作在後面第5節講述。
3.7 字幕解碼線程
實作細節略。以後有機會研究字幕時,再作補充。
「視訊雲技術」你最值得關注的音視訊技術公衆号,每周推送來自阿裡雲一線的實踐技術文章,在這裡與音視訊領域一流工程師交流切磋。