一步步進行ffmpeg的C語言音視頻編程

本文以ffmpeg的C語言編程過程,講述簡單的音視頻轉碼過程
更多內容可以訪問我的博客

前言

本文以 ffmpeg 工具,講述如何認識音視頻編程,你可以瞭解到常見視頻格式的大概樣子,一步步學會如何使用 ffmpeg 的 C 語言 API

本文重於動手實踐,代碼倉庫:mpegUtil

筆者的開發環境:Arch Linux 4.19.12, ffmpeg version n4.1

解碼過程總覽

以下是解碼流程圖,逆向即是編碼流程

本文是音視頻編程入門篇,先略過傳輸協議層,主要講格式層與編解碼層的編程例子。

寫在最前面的日誌處理

邊編程邊執行,查看日誌輸出,是最直接的反饋,以感受學習的進度。對於 ffmpeg 的日誌,需要提前這樣處理:

/* log.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <libavutil/log.h>

// 定義輸出日誌的函數,留白給使用者實現
extern void Ffmpeglog(int , char*);

static void log_callback(void *avcl, int level, const char *fmt, va_list vl) 
{
    (void) avcl;
    char log[1024] = {0};
    int n = vsnprintf(log, 1024, fmt, vl);
    if (n > 0 && log[n - 1] == '\n')
        log[n - 1] = 0;
    if (strlen(log) == 0)
        return;
    Ffmpeglog(level, log);
}

void set_log_callback()
{
    // 給 av 解碼器註冊日誌回調函數
    av_log_set_callback(log_callback);
}
/* main.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void Ffmpeglog(int l, char* t) {
    if(l <= AV_LOG_INFO)
        fprintf(stdout, "%s\n", t);
}

ffmpeg 有不同等級的日誌,本文只需使用 AV_LOG_INFO 即可。

第一步,查看音視頻格式信息

料理食材的第一步,得先懂得食材的來源和特性。

  • 來源,互聯網在線觀看(http/rtmp)、播放設備上存儲的視頻文件(file)。
  • 格式,如何查看視頻文件的格式呢,以下有 unix 命令行示例,至於 windows 系統,查看文件屬性即可。
# linux 上查看視頻文件信息
ffmpeg -i example.mp4

以某個mp4文件爲例,輸出:

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'example.mp4':
  Metadata:
    major_brand     : isom
    minor_version   : 512
    compatible_brands: isomiso2avc1mp41
    encoder         : Wxmm_900012345
  Duration: 00:00:58.21, start: 0.000000, bitrate: 541 kb/s
    Stream #0:0(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 368x640, 487 kb/s, 24 fps, 24 tbr, 12288 tbn, 48 tbc (default)
    Metadata:
      handler_name    : VideoHandler
    Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 48 kb/s (default)
    Metadata:
      handler_name    : SoundHandler

根據命令輸出信息,視頻文件中有兩個 stream, 即 video 與 audio,視頻流與音頻流。

  • stream 0, 是視頻數據,編碼格式爲h264,24 fps 意爲 24 frame per second,即每秒24幀,比特率487 kb/s,
  • stream 1, 是音頻數據,編碼格式爲acc,採樣率44100 Hz,比特率48 kb/s

【編程實操】讀取音視頻流的格式信息

在互聯網場景中,在線觀看視頻纔是常見需求,那麼,計算機如何讀取視頻流的信息呢,下面以 ffmpeg 代碼講述

    /* C代碼例子,省略了處理錯誤的邏輯 */

    AVFormatContext *fmt_ctx = NULL; // AV 格式上下文
    AVIOContext *avio_ctx = NULL; // AV IO 上下文
    unsigned char *avio_ctx_buffer = NULL; // input buffer

    fmt_ctx = avformat_alloc_context();// 獲得 AV format 句柄
    avio_ctx_buffer = (unsigned char *)av_malloc(data_size); // ffmpeg分配內存的方法,給輸入分配緩存

    /* fread(file) or memcpy(avio_ctx_buffer, inBuf, n)  省略拷貝文件流的步驟 */

    // 給 av format context 分配 io 操作的句柄,必要傳參:輸入數據的指針、數據大小、write_flag=0表明buffer不可寫,其他參數忽略,置 NULL
    avio_ctx = avio_alloc_context(avio_ctx_buffer, data_size, 0, NULL, NULL, NULL, NULL);
    fmt_ctx->pb = avio_ctx;

    // 打開輸入的數據流,讀取 header 的格式內容,注意必須的後續處理 avformat_close_input()
    avformat_open_input(&fmt_ctx, NULL, NULL, NULL)

    // 獲取音視頻流的信息
    avformat_find_stream_info(fmt_ctx, NULL) 

ffmpeg 有一個方法直接打印音視頻信息

av_dump_format(fmt_ctx, 0, NULL, 0);

print: (源碼的輸出格式凌亂,筆者整理過)

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '(null)':
Stream #0:0 : Video: h264 (High) (avc1 / 0x31637661), yuv420p, 368x640, 487 kb/s 24 fps,
Stream #0:1 : Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 48 kb/s

實踐編程,獲取音視頻信息,當然需要細緻地調用API,看下面代碼

  • 查看音視頻流的索引、類型、解碼器類型
  avformat_find_stream_info(fmt_ctx, NULL);

  for(int i=0; i<fmt_ctx->nb_streams; i++){
      AVStream *stream = fmt_ctx->streams[i];
      AVCodecParameters *codec_par = stream->codecpar;
      av_log(NULL, AV_LOG_INFO, "find audio stream index=%d, type=%s, codec id=%d", 
        i, av_get_media_type_string(codec_par->codec_type), codec_par->codec_id);
  }

print:

find audio stream index=0, type=video, codec id=27
find audio stream index=1, type=audio, codec id=86018
  • 看到沒,上面代碼只獲得解碼器的id值(枚舉類型),那麼解碼器的信息呢,加上下面代碼,可以看到音視頻流的格式,同時獲得解碼器,方便“解碼步驟”使用。
  AVCodec *decodec = NULL;
  decodec = avcodec_find_decoder(codec_par->codec_id); // 獲得解碼器
  av_log(NULL, AV_LOG_INFO, "find codec name=%s\t%s", decodec->name, decodec->long_name);

print:

find audio stream index=0, type=video, codec id=27
find codec name=h264    H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10
find audio stream index=1, type=audio, codec id=86018
find codec name=aac     AAC (Advanced Audio Coding)
  • 關於視頻,可以查看幀率(一秒有多少幀畫面)
  // 獲得一個分數
  AVRational framerate = av_guess_frame_rate(fmt_ctx, stream, NULL);
  av_log(NULL, AV_LOG_INFO, "video framerate=%d/%d", framerate.num, framerate.den);

print:

video framerate=24/1

至此,我們掌握瞭如何利用 ffmpeg 的 C 語言 API 來讀取音視頻文件流的信息

第二步,解碼

簡單說一下音視頻文件的解碼過程,對大部分音視頻格式來說,在原始流的數據中,不同類型的流會按時序先後交錯在一起,這是多路複用,這樣的數據分佈,即有利於播放器打開本地文件,讀取某一時段的音視頻時,方便進行fseek操作(移動文件描述符的讀寫指針);也有利於網絡在線觀看視頻,“空投”從某一刻開始播放視頻,從文件某一段下載數據。

直觀的看下面的循環讀取文件流的代碼

    /* begin 解碼過程 */
    AVPacket *pkt;
    AVFrame *frame;

    // 分配原始文件流packet的緩存
    pkt = av_packet_alloc();
    // 分配 AV 幀 的內存
    frame = av_frame_alloc();

    // 在循環中不斷讀取下一個文件流的 packet 包
    while (av_read_frame(fmt_ctx, pkt) >= 0) {
      if(pkt->size){
            /*
            demux 解複用
            原始流的數據中,不同格式的流會交錯在一起(多路複用)
            從原始流中讀取的每一個 packet 的流可能是不一樣的,需要判斷 packet 的流索引,按類型處理
            */
            if(pkt->stream_index == video_stream_idx){
              // 此處省略處理視頻的邏輯
            }else if(pkt->stream_index == audio_stream_idx){
              // 此處省略處理音頻的邏輯
            }
        }
        av_packet_unref(pkt);
        av_frame_unref(frame);
    }
    /* end 解碼過程 */

    // flush data
    avcodec_send_packet(video_decodec_ctx, NULL);
    avcodec_send_packet(audio_decodec_ctx, NULL);

上面代碼是對音視頻流進行解複用的主要過程,在循環中分別處理不同類型的流數據,到了這一步,就是使用解碼器對循環中獲取的 packet 包進行解碼。

解碼前的準備

ffmepg 中,解碼工具需要初始化好兩個指針,一個是解碼器,一個是解碼器上下文,上下文是用來存儲此次操作的變量集合,比如 io 的句柄、解碼的幀數累加值,視頻的幀率等等。讓我們重新編寫上面讀取音視頻流的循環,給音視頻流分別分配好這兩個指針,並且處理好錯誤返回值。(下面代碼的 goto 語句暫且略過,後面再提)

   // find codec
    int video_stream_idx = -1, audio_stream_idx = -1;
    AVStream *video_stream = NULL, *audio_stream = NULL;
    AVCodecContext *video_decodec_ctx=NULL, *audio_decodec_ctx=NULL;

    // AVFormatContext.nb_stream 記錄了該 URL 中包含有幾路流
    for(int i=0; i<fmt_ctx->nb_streams; i++){
        AVStream *stream = fmt_ctx->streams[i];
        AVCodecParameters *codec_par = stream->codecpar;
        AVCodec *decodec = NULL;
        AVCodecContext *decodec_ctx = NULL;

        av_log(NULL, AV_LOG_INFO, "find audio stream index=%d, type=%s, codec id=%d", 
                i, av_get_media_type_string(codec_par->codec_type), codec_par->codec_id);

        // 獲得解碼器
        decodec = avcodec_find_decoder(codec_par->codec_id);
        if(!decodec){
            av_log(NULL, AV_LOG_ERROR, "fail to find decodec\n");
            goto clean2;
        }

        av_log(NULL, AV_LOG_INFO, "find codec name=%s\t%s", decodec->name, decodec->long_name);

        // 分配解碼器上下文句柄
        decodec_ctx = avcodec_alloc_context3(decodec);
        if(!decodec_ctx){
            av_log(NULL, AV_LOG_ERROR, "fail to allocate codec context\n");
            goto clean2;
        }

        // 複製流信息到解碼器上下文
        if(avcodec_parameters_to_context(decodec_ctx, codec_par) < 0){
            av_log(NULL, AV_LOG_ERROR, "fail to copy codec parameters to decoder context\n");
            avcodec_free_context(&decodec_ctx);
            goto clean2;
        }

        // 初始化解碼器
        if ((ret = avcodec_open2(decodec_ctx, decodec, NULL)) < 0) {
            av_log(NULL, AV_LOG_ERROR, "Failed to open %s codec\n", decodec->name);
            return ret;
        }

        if( stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO){
            // 視頻的屬性,幀率,這裏 av_guess_frame_rate() 非必須,看業務是否需要使用幀率參數
            decodec_ctx->framerate = av_guess_frame_rate(fmt_ctx, stream, NULL);
            av_log(NULL, AV_LOG_INFO, "video framerate=%d/%d", decodec_ctx->framerate.num, decodec_ctx->framerate.den);
            video_stream_idx = i;
            video_stream = stream;
            video_decodec_ctx = decodec_ctx;
        } else if( stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO){
            audio_stream_idx = i;
            audio_stream = stream;
            audio_decodec_ctx = decodec_ctx;
        } 
    }

以上方式是循環讀取文件的所有stream,便於查看文件中有什麼流,如視頻、音頻、字幕等,若是業務需求,只要對單獨一個流(比如視頻),可以用以下方式獲取特定的流。

    if (avformat_find_stream_info(fmt_ctx, NULL) < 0) {
        av_log(NULL, AV_LOG_ERROR, "Could not find stream information\n");
        goto clean1;
    }
    ret = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, NULL, 0);
    if (ret < 0) {
        av_log(NULL, AV_LOG_ERROR, "Could not find %s stream\n",
                av_get_media_type_string(type));
        return ret;
    }
    int stream_index = ret;
    AVStream *st = fmt_ctx->streams[stream_index];

解碼的循環

修改上面解碼的循環,以視頻流爲例,如何從流中讀取幀,爲便於理解,在關鍵地方有清楚的註釋。

    while (av_read_frame(fmt_ctx, pkt) >= 0) {
        if(pkt->size){
            /*
            demux 解複用
            原始流的數據中,不同格式的流會交錯在一起(多路複用)
            從原始流中讀取的每一個 packet 的流可能是不一樣的,需要判斷 packet 的流索引,按類型處理
            */
            if(pkt->stream_index == video_stream_idx){
                // 向解碼器發送原始壓縮數據 packet
                if((ret = avcodec_send_packet(video_decodec_ctx, pkt)) < 0){
                    av_log(NULL, AV_LOG_ERROR, "Error sending a packet for decoding, ret=%d", ret);
                    break;
                }
                /*
                解碼輸出視頻幀
                avcodec_receive_frame()返回 EAGAIN 表示需要更多幀來參與編碼
                像 MPEG等格式, P幀(預測幀)需要依賴I幀(關鍵幀)或者前面的P幀,使用比較或者差分方式編碼
                讀取frame需要循環,因爲讀取多個packet後,可能獲得多個frame
                */ 
                while(ret >= 0){
                    ret = avcodec_receive_frame(video_decodec_ctx, frame);
                    if(ret == AVERROR(EAGAIN) || ret == AVERROR_EOF){
                        break;
                    }

                    /* 
                    DEBUG 打印出視頻的時間
                    pts = display timestamp
                    視頻流有基準時間 time_base ,即每 1 pts 的時間間隔(單位秒)
                    使用 pts * av_q2d(time_base) 可知當前幀的顯示時間
                    */
                    if(video_decodec_ctx->frame_number%100 == 0){
                        av_log(NULL, AV_LOG_INFO, "read video No.%d frame, pts=%d, timestamp=%f seconds", 
                            video_decodec_ctx->frame_number, frame->pts, frame->pts * av_q2d(video_stream->time_base));
                    }

                    /*
                    在第一個視頻幀讀取成功時,可以進行:
                    1、若要轉碼,初始化相應的編碼器
                    2、若要加過濾器,比如水印、旋轉等,這裏初始化 filter
                    */
                    if (video_decodec_ctx->frame_number == 1) {

                    }else{

                    }
                    av_frame_unref(frame);
                }
            }else if(pkt->stream_index == audio_stream_idx){

            }
        }

        av_packet_unref(pkt);
        av_frame_unref(frame); 
    }

回收內存

上文的代碼中,多次出現 goto 語句,我認爲適當的使用 goto 使編程更加方便,比如執行過程結束的清理工作,以下是回收 ffmpeg AV 庫產生的各種變量的內存,C/C++語言編程都需要多注意這一點。

clean5:
    av_frame_free(&frame);
    //av_parser_close(parser);
clean4:
    av_packet_free(&pkt);
clean3:
    if(NULL != video_decodec_ctx)
        avcodec_free_context(&video_decodec_ctx);
    if(NULL != audio_decodec_ctx)
        avcodec_free_context(&audio_decodec_ctx);
clean2:
    av_freep(&fmt_ctx->pb->buffer);
    av_freep(&fmt_ctx->pb);
clean1:
    avformat_close_input(&fmt_ctx);
end:
    return ret;

自由發揮

看到了這裏,可以說入門 ffmpeg 編程了,什麼,你問後面的轉碼怎麼做?筆者就留白了,本文已經介紹了最基本的解碼過程了,編碼也就是逆向過程,我建議閱讀 ffmepg 官方源碼的example,以及多瞭解音視頻各種格式的知識。

實際例子

我提供兩個小例子在 github 上

請安裝好 linux 下 ffmepg 環境,找到例子代碼裏的 Makefile 文件編譯,例如:

make -f Makefile_test_dump_info

以後我會將這兩個小例子修改,實現跨語言調用,如 nodejs addon 或 golang cgo

Reference

ffmpeg example (本文代碼就是從example改過來的)

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