ffmpeg簡易播放器的實現-完善版
實驗平臺:openSUSE Leap 42.3
FFmpeg版本:4.1
SDL版本:2.0.9
基於FFmpeg和SDL實現的簡易視訊播放器,主要分為讀取視訊檔案解碼和呼叫SDL顯示兩大部分。詳細流程可參考程式碼註釋。
本篇實驗筆記主要參考如下兩篇文章:
[1]. 最簡單的基於FFMPEG+SDL的視訊播放器ver2(採用SDL2.0)
[2]. An ffmpeg and SDL Tutorial
1. 播放器基本原理
下圖及解釋內容引用自“雷霄驊,視音訊編解碼技術零基礎學習方法”,
因原圖太小,看不太清楚,故重新制作了一張圖片。
解協議
將流媒體協議的資料,解析為標準的相應的封裝格式資料。視音訊在網路上傳播的時候,常常採用各種流媒體協議,
例如HTTP,RTMP,或是MMS等等。這些協議在傳輸視音訊資料的同時,也會傳輸一些信令資料。這些信令資料包括
對播放的控制(播放,暫停,停止),或者對網路狀態的描述等。解協議的過程中會去除掉信令資料而只保留視音
頻資料。例如,採用RTMP協議傳輸的資料,經過解協議操作後,輸出FLV格式的資料。解封裝
將輸入的封裝格式的資料,分離成為音訊流壓縮編碼資料和視訊流壓縮編碼資料。封裝格式種類很多,例如MP4,
MKV,RMVB,TS,FLV,AVI等等,它的作用就是將已經壓縮編碼的視訊資料和音訊資料按照一定的格式放到一起。
例如,FLV格式的資料,經過解封裝操作後,輸出H.264編碼的視訊碼流和AAC編碼的音訊碼流。解碼
將視訊/音訊壓縮編碼資料,解碼成為非壓縮的視訊/音訊原始資料。音訊的壓縮編碼標準包含AAC,MP3,AC-3等等,
視訊的壓縮編碼標準則包含H.264,MPEG2,VC-1等等。解碼是整個系統中最重要也是最複雜的一個環節。通過解碼,
壓縮編碼的視訊資料輸出成為非壓縮的顏色資料,例如YUV420P,RGB等等;壓縮編碼的音訊資料輸出成為非壓縮的
音訊抽樣資料,例如PCM資料。視音訊同步
根據解封裝模組處理過程中獲取到的引數資訊,同步解碼出來的視訊和音訊資料,並將視訊音訊資料送至系統的顯
卡和音效卡播放出來。
2. 原始碼清單
/******************************************************************************* * ffplayer.c * * history: * 2018-11-27 - [lei] Create file: a simplest ffmpeg player * 2018-11-29 - [lei] Refresh decoding thread with SDL event * * details: * A simple ffmpeg player. * * refrence: * 1. https://blog.csdn.net/leixiaohua1020/article/details/38868499 * 2. http://dranger.com/ffmpeg/ffmpegtutorial_all.html#tutorial01.html * 3. http://dranger.com/ffmpeg/ffmpegtutorial_all.html#tutorial02.html *******************************************************************************/ #include <stdio.h> #include <stdbool.h> #include <libavcodec/avcodec.h> #include <libavformat/avformat.h> #include <libswscale/swscale.h> #include <SDL2/SDL.h> #include <SDL2/SDL_video.h> #include <SDL2/SDL_render.h> #include <SDL2/SDL_rect.h> #define SDL_USEREVENT_REFRESH (SDL_USEREVENT + 1) static bool s_playing_exit = false; static bool s_playing_pause = false; // 每40ms傳送一個解碼重新整理事件,使解碼器以25FPS的幀率工作 int sdl_thread_handle_refreshing(void *opaque) { SDL_Event sdl_event; while (!s_playing_exit) { if (!s_playing_pause) { sdl_event.type = SDL_USEREVENT_REFRESH; SDL_PushEvent(&sdl_event); } SDL_Delay(40); } return 0; } int main(int argc, char *argv[]) { // Initalizing these to NULL prevents segfaults! AVFormatContext* p_fmt_ctx = NULL; AVCodecContext* p_codec_ctx = NULL; AVCodecParameters* p_codec_par = NULL; AVCodec* p_codec = NULL; AVFrame* p_frm_raw = NULL; // 幀,由包解碼得到原始幀 AVFrame* p_frm_yuv = NULL; // 幀,由原始幀色彩轉換得到 AVPacket* p_packet = NULL; // 包,從流中讀出的一段資料 struct SwsContext* sws_ctx = NULL; int buf_size; uint8_t* buffer = NULL; int i; int v_idx; int ret; int res; SDL_Window* screen; SDL_Renderer* sdl_renderer; SDL_Texture* sdl_texture; SDL_Rect sdl_rect; SDL_Thread* sdl_thread; SDL_Event sdl_event; res = 0; if (argc < 2) { printf("Please provide a movie file\n"); return -1; } // 初始化libavformat(所有格式),註冊所有複用器/解複用器 // av_register_all(); // 已被申明為過時的,直接不再使用即可 // A1. 開啟視訊檔案:讀取檔案頭,將檔案格式資訊儲存在"fmt context"中 ret = avformat_open_input(&p_fmt_ctx, argv[1], NULL, NULL); if (ret != 0) { printf("avformat_open_input() failed %d\n", ret); res = -1; goto exit0; } // A2. 搜尋流資訊:讀取一段視訊檔案資料,嘗試解碼,將取到的流資訊填入pFormatCtx->streams // p_fmt_ctx->streams是一個指標陣列,陣列大小是pFormatCtx->nb_streams ret = avformat_find_stream_info(p_fmt_ctx, NULL); if (ret < 0) { printf("avformat_find_stream_info() failed %d\n", ret); res = -1; goto exit1; } // 將檔案相關資訊列印在標準錯誤裝置上 av_dump_format(p_fmt_ctx, 0, argv[1], 0); // A3. 查詢第一個視訊流 v_idx = -1; for (i=0; i<p_fmt_ctx->nb_streams; i++) { if (p_fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) { v_idx = i; printf("Find a video stream, index %d\n", v_idx); break; } } if (v_idx == -1) { printf("Cann't find a video stream\n"); res = -1; goto exit1; } // A5. 為視訊流構建解碼器AVCodecContext // A5.1 獲取解碼器引數AVCodecParameters p_codec_par = p_fmt_ctx->streams[v_idx]->codecpar; // A5.2 獲取解碼器 p_codec = avcodec_find_decoder(p_codec_par->codec_id); if (p_codec == NULL) { printf("Cann't find codec!\n"); res = -1; goto exit1; } // A5.3 構建解碼器AVCodecContext // A5.3.1 p_codec_ctx初始化:分配結構體,使用p_codec初始化相應成員為預設值 p_codec_ctx = avcodec_alloc_context3(p_codec); // A5.3.2 p_codec_ctx初始化:p_codec_par ==> p_codec_ctx,初始化相應成員 ret = avcodec_parameters_to_context(p_codec_ctx, p_codec_par); if (ret < 0) { printf("avcodec_parameters_to_context() failed %d\n", ret); res = -1; goto exit2; } // A5.3.3 p_codec_ctx初始化:使用p_codec初始化p_codec_ctx,初始化完成 ret = avcodec_open2(p_codec_ctx, p_codec, NULL); if (ret < 0) { printf("avcodec_open2() failed %d\n", ret); res = -1; goto exit2; } // A6. 分配AVFrame // A6.1 分配AVFrame結構,注意並不分配data buffer(即AVFrame.*data[]) p_frm_raw = av_frame_alloc(); if (p_frm_raw == NULL) { printf("av_frame_alloc() for p_frm_raw failed\n"); res = -1; goto exit2; } p_frm_yuv = av_frame_alloc(); if (p_frm_yuv == NULL) { printf("av_frame_alloc() for p_frm_raw failed\n"); res = -1; goto exit3; } // A6.2 為AVFrame.*data[]手工分配緩衝區,用於儲存sws_scale()中目的幀視訊資料 // p_frm_raw的data_buffer由av_read_frame()分配,因此不需手工分配 // p_frm_yuv的data_buffer無處分配,因此在此處手工分配 buf_size = av_image_get_buffer_size(AV_PIX_FMT_YUV420P, p_codec_ctx->width, p_codec_ctx->height, 1 ); // buffer將作為p_frm_yuv的視訊資料緩衝區 buffer = (uint8_t *)av_malloc(buf_size); if (buffer == NULL) { printf("av_malloc() for buffer failed\n"); res = -1; goto exit4; } // 使用給定引數設定p_frm_yuv->data和p_frm_yuv->linesize ret = av_image_fill_arrays(p_frm_yuv->data, // dst data[] p_frm_yuv->linesize, // dst linesize[] buffer, // src buffer AV_PIX_FMT_YUV420P, // pixel format p_codec_ctx->width, // width p_codec_ctx->height, // height 1 // align ); if (ret < 0) { printf("av_image_fill_arrays() failed %d\n", ret); res = -1; goto exit5; } // A7. 初始化SWS context,用於後續影象轉換 sws_ctx = sws_getContext(p_codec_ctx->width, // src width p_codec_ctx->height, // src height p_codec_ctx->pix_fmt, // src format p_codec_ctx->width, // dst width p_codec_ctx->height, // dst height AV_PIX_FMT_YUV420P, // dst format SWS_BICUBIC, // flags NULL, // src filter NULL, // dst filter NULL // param ); if (sws_ctx == NULL) { printf("sws_getContext() failed\n"); res = -1; goto exit6; } // B1. 初始化SDL子系統:預設(事件處理、檔案IO、執行緒)、視訊、音訊、定時器 if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) { printf("SDL_Init() failed: %s\n", SDL_GetError()); res = -1; goto exit6; } // B2. 建立SDL視窗,SDL 2.0支援多視窗 // SDL_Window即執行程式後彈出的視訊視窗,同SDL 1.x中的SDL_Surface screen = SDL_CreateWindow("Simplest ffmpeg player's Window", SDL_WINDOWPOS_UNDEFINED,// 不關心視窗X座標 SDL_WINDOWPOS_UNDEFINED,// 不關心視窗Y座標 p_codec_ctx->width, p_codec_ctx->height, SDL_WINDOW_OPENGL ); if (screen == NULL) { printf("SDL_CreateWindow() failed: %s\n", SDL_GetError()); res = -1; goto exit7; } // B3. 建立SDL_Renderer // SDL_Renderer:渲染器 sdl_renderer = SDL_CreateRenderer(screen, -1, 0); if (sdl_renderer == NULL) { printf("SDL_CreateRenderer() failed: %s\n", SDL_GetError()); res = -1; goto exit7; } // B4. 建立SDL_Texture // 一個SDL_Texture對應一幀YUV資料,同SDL 1.x中的SDL_Overlay sdl_texture = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, p_codec_ctx->width, p_codec_ctx->height); if (sdl_texture == NULL) { printf("SDL_CreateTexture() failed: %s\n", SDL_GetError()); res = -1; goto exit7; } sdl_rect.x = 0; sdl_rect.y = 0; sdl_rect.w = p_codec_ctx->width; sdl_rect.h = p_codec_ctx->height; p_packet = (AVPacket *)av_malloc(sizeof(AVPacket)); if (p_packet == NULL) { printf("SDL_CreateThread() failed: %s\n", SDL_GetError()); res = -1; goto exit7; } // B5. 建立定時重新整理事件執行緒,按照預設幀率產生重新整理事件 sdl_thread = SDL_CreateThread(sdl_thread_handle_refreshing, NULL, NULL); if (sdl_thread == NULL) { printf("SDL_CreateThread() failed: %s\n", SDL_GetError()); res = -1; goto exit8; } while (1) { // B6. 等待重新整理事件 SDL_WaitEvent(&sdl_event); if (sdl_event.type == SDL_USEREVENT_REFRESH) { // A8. 從視訊檔案中讀取一個packet // packet可能是視訊幀、音訊幀或其他資料,解碼器只會解碼視訊幀或音訊幀,非音視訊資料並不會被 // 扔掉、從而能向解碼器提供儘可能多的資訊 // 對於視訊來說,一個packet只包含一個frame // 對於音訊來說,若是幀長固定的格式則一個packet可包含整數個frame, // 若是幀長可變的格式則一個packet只包含一個frame while (av_read_frame(p_fmt_ctx, p_packet) == 0) { if (p_packet->stream_index == v_idx) // 取到一幀視訊幀,則退出 { break; } } // A9. 視訊解碼:packet ==> frame // A9.1 向解碼器喂資料,一個packet可能是一個視訊幀或多個音訊幀,此處音訊幀已被上一句濾掉 ret = avcodec_send_packet(p_codec_ctx, p_packet); if (ret != 0) { printf("avcodec_send_packet() failed %d\n", ret); res = -1; goto exit8; } // A9.2 接收解碼器輸出的資料,此處只處理視訊幀,每次接收一個packet,將之解碼得到一個frame ret = avcodec_receive_frame(p_codec_ctx, p_frm_raw); if (ret != 0) { if (ret == AVERROR_EOF) { printf("avcodec_receive_frame(): the decoder has been fully flushed\n"); } else if (ret == AVERROR(EAGAIN)) { printf("avcodec_receive_frame(): output is not available in this state - " "user must try to send new input\n"); } else if (ret == AVERROR(EINVAL)) { printf("avcodec_receive_frame(): codec not opened, or it is an encoder\n"); } else { printf("avcodec_receive_frame(): legitimate decoding errors\n"); } res = -1; goto exit8; } // A10. 影象轉換:p_frm_raw->data ==> p_frm_yuv->data // 將源影象中一片連續的區域經過處理後更新到目標影象對應區域,處理的影象區域必須逐行連續 // plane: 如YUV有Y、U、V三個plane,RGB有R、G、B三個plane // slice: 影象中一片連續的行,必須是連續的,順序由頂部到底部或由底部到頂部 // stride/pitch: 一行影象所佔的空間位元組數,Stride = BytesPerPixel * Width,4位元組對齊 // AVFrame.*data[]: 每個陣列元素指向對應plane // AVFrame.linesize[]: 每個陣列元素表示對應plane中一行影象所佔的空間位元組數 sws_scale(sws_ctx, // sws context (const uint8_t *const *)p_frm_raw->data, // src slice p_frm_raw->linesize, // src stride 0, // src slice y p_codec_ctx->height, // src slice height p_frm_yuv->data, // dst planes p_frm_yuv->linesize // dst strides ); // B7. 使用新的YUV畫素資料更新SDL_Rect SDL_UpdateYUVTexture(sdl_texture, // sdl texture &sdl_rect, // sdl rect p_frm_yuv->data[0], // y plane p_frm_yuv->linesize[0], // y pitch p_frm_yuv->data[1], // u plane p_frm_yuv->linesize[1], // u pitch p_frm_yuv->data[2], // v plane p_frm_yuv->linesize[2] // v pitch ); // B8. 使用特定顏色清空當前渲染目標 SDL_RenderClear(sdl_renderer); // B9. 使用部分影象資料(texture)更新當前渲染目標 SDL_RenderCopy(sdl_renderer, // sdl renderer sdl_texture, // sdl texture NULL, // src rect, if NULL copy texture &sdl_rect // dst rect ); // B10. 執行渲染,更新螢幕顯示 SDL_RenderPresent(sdl_renderer); av_packet_unref(p_packet); } else if (sdl_event.type == SDL_KEYDOWN) { printf("SDL event KEYDOWN\n"); if (sdl_event.key.keysym.sym == SDLK_SPACE) { // 使用者按空格鍵,暫停/繼續狀態切換 printf("player %s\n", s_playing_pause ? "pause" : "continue"); s_playing_pause = !s_playing_pause; } } else if (sdl_event.type == SDL_QUIT) { // 使用者按下關閉視窗按鈕 printf("SDL event QUIT\n"); s_playing_exit = true; break; } else { // printf("Ignore SDL event 0x%04X\n", sdl_event.type); } } exit8: SDL_Quit(); exit7: av_packet_unref(p_packet); exit6: sws_freeContext(sws_ctx); exit5: av_free(buffer); exit4: av_frame_free(&p_frm_yuv); exit3: av_frame_free(&p_frm_raw); exit2: avcodec_close(p_codec_ctx); exit1: avformat_close_input(&p_fmt_ctx); exit0: return res; }
2.1 流程簡述
流程比較簡單,不畫流程圖了,簡述如下:
media file ---[decode]---> raw frame ---[scale]---> yuv frame ---[SDL]---> display
media file --------------> p_frm_raw -------------> p_frm_yuv -----------> sdl_renderer
加上相關關鍵函式後,流程如下:
media_file ---[av_read_frame()]----------->
p_packet ---[avcodec_send_packet()]----->
decoder ---[avcodec_receive_frame()]--->
p_frm_raw ---[sws_scale]----------------->
p_frm_yuv ---[SDL_UpdateYUVTexture()]---->
display
2.2 相關概念
原始碼清單中涉及的一些概念簡述如下:
container:
對應資料結構AVFormatContext
封裝器,將流資料封裝為指定格式的檔案,檔案格式如AVI、MP4等。
FFmpeg可識別五種流型別:視訊video(v)、音訊audio(a)、attachment(t)、資料data(d)、字幕subtitle。
codec:
對應資料結構AVCodec
編解碼器。編碼器將未壓縮的原始影象或音訊資料編碼為壓縮資料。解碼器與之相反。
codec context:
對應資料結構AVCodecContext
編解碼器上下文。此為非常重要的一個數據結構,後文分析。各API大量使用AVCodecContext來引用編解碼器。
codec par:
對應資料結構AVCodecParameters
編解碼器引數。新版本增加的欄位。新版本建議使用AVStream->codepar替代AVStream->codec。
packet:
對應資料結構AVPacket
經過編碼的資料。通過av_read_frame()從媒體檔案中獲取得到的一個packet可能包含多個(整數個)音訊幀或單個
視訊幀,或者其他型別的流資料。
frame:
對應資料結構AVFrame
解碼後的原始資料。解碼器將packet解碼後生成frame。
plane:
如YUV有Y、U、V三個plane,RGB有R、G、B三個plane
slice:
影象中一片連續的行,必須是連續的,順序由頂部到底部或由底部到頂部
stride/pitch:
一行影象所佔的空間位元組數,Stride = BytesPerPixel * Width,x位元組對齊[待確認]
sdl window:
對應資料結構SDL_Window
播放視訊時彈出的視窗。在SDL1.x版本中,只可以建立一個視窗。在SDL2.0版本中,可以建立多個視窗。
sdl texture:
對應資料結構SDL_Texture
一個SDL_Texture對應一幀解碼後的影象資料。
sdl renderer:
對應資料結構SDL_Renderer
渲染器。將SDL_Texture渲染至SDL_Window。
sdl rect:
對應資料結構SDL_Rect
SDL_Rect用於確定SDL_Texture顯示的位置。一個SDL_Window上可以顯示多個SDL_Rect。這樣可以實現同一視窗的分屏顯示。
2.3 幀率控制-定時重新整理機制
將上一版程式碼拆分為兩個執行緒:定時重新整理執行緒 + 解碼主執行緒。
定時重新整理執行緒每40ms傳送一個自定義SDL事件,通知解碼主執行緒
解碼主執行緒收到SDL事件後,獲取一個視訊幀解碼並顯示
3、編譯
gcc -o ffplayer ffplayer.c -lavutil -lavformat -lavcodec -lavutil -lswscale -lSDL2
4、測試
./ffplayer 480x272.h265
5、參考資料
[1] 雷霄驊,視音訊編解碼技術零基礎學習方法”
[2] 雷霄驊,FFmpeg原始碼簡單分析:常見結構體的初始化和銷燬(AVFormatContext,AVFrame等)
[3] 雷霄驊,最簡單的基於FFMPEG+SDL的視訊播放器ver2(採用SDL2.0)
[4] Martin Bohme, An ffmpeg and SDL Tutorial, Tutorial 01: Making Screencaps
[5] Martin Bohme, An ffmpeg and SDL Tutorial, Tutorial 02: Outputting to the Screen
[6] YUV影象裡的stride和plane的解釋
[7] 圖文詳解YUV420資料格式
[8] YUV,https://zh.wikipedia.org/wiki/YUV
6、修改記錄
2018-11-23 V1.0 初稿
2018-11-29 V1.1 增加定時重新整理執行緒,使解碼幀率更加準確