FFmpeg流媒體處理-收流與推流

本文爲做者原創,轉載請註明出處:http://www.javashuo.com/article/p-xqiatytd-q.htmlhtml

1. 簡介

流媒體是使用了流式傳輸的多媒體應用技術。以下是維基百科關於流媒體概念的定義:nginx

流媒體(streaming media)是指將一連串的媒體數據壓縮後,通過網絡分段發送數據,在網絡上即時傳輸影音以供觀賞的一種技術與過程,此技術使得數據包得以像流水同樣發送;若是不使用此技術,就必須在使用前下載整個媒體文件。git

關於流媒體的基礎概念,觀止雲的「流媒體|從入門到出家」系列文章極具參考價值,請參考本文第5節參考資料部分。github

流媒體系統是一個比較複雜的系統,簡單來講涉及三個角色:流媒體服務器、推流客戶端和收流客戶端。推流客戶端是內容生產者,收流客戶端是內容消費者。示意圖以下:
流媒體系統示意簡圖docker

FFmpeg中對影音數據的處理,能夠劃分爲協議層、容器層、編碼層與原始數據層四個層次。協議層提供網絡協議收發功能,能夠接收或推送含封裝格式的媒體流。協議層由libavformat庫及第三方庫(如librtmp)提供支持。容器層處理各類封裝格式。容器層由libavformat庫提供支持。編碼層處理音視頻編碼及解碼。編碼層由各類豐富的編解碼器(libavcodec庫及第三方編解碼庫(如libx264))提供支持。原始數據層處理未編碼的原始音視頻幀。原始數據層由各類豐富的音視頻濾鏡(libavfilter庫)提供支持。shell

本文說起的收流與推流的功能,屬於協議層的處理。FFmpeg中libavformat庫提供了豐富的協議處理及封裝格式處理功能,在打開輸入/輸出時,FFmpeg會根據輸入URL/輸出URL探測輸入/輸出格式,選擇合適的協議和封裝格式。例如,若是輸出URL是rtmp://192.168.0.104/live,那麼FFmpeg打開輸出時,會肯定使用rtmp協議,封裝格式爲flv。FFmpeg中打開輸入/輸出的內部處理細節用戶沒必要關注,所以本文流處理的例程和前面轉封裝的例程很是類似,不一樣之處主要在於輸入/輸出URL形式不一樣,若URL攜帶「rtmp://」、「rpt://」、「udp://」等前綴,則表示涉及流處理;不然,處理的是本地文件。json

若是輸入是網絡流,輸出是本地文件,則實現的是拉流功能,將網絡流存儲爲本地文件,以下:
收流服務器

若是輸入是本地文件,輸出是網絡流,則實現的是推流功能,將本地文件推送到網絡,以下:
推流網絡

若是輸入是網絡流,輸出也是網絡流,則實現的是轉流功能,將一個流媒體服務器上的流推送到另外一個流媒體服務器,以下:
轉流app

2. 源碼

源碼和轉封裝例程大部分相同,能夠認爲是轉封裝例程的加強版:

#include <stdbool.h>
#include <libavutil/timestamp.h>
#include <libavformat/avformat.h>

// ffmpeg -re -i tnhaoxc.flv -c copy -f flv rtmp://192.168.0.104/live
// ffmpeg -i rtmp://192.168.0.104/live -c copy tnlinyrx.flv
// ./streamer tnhaoxc.flv rtmp://192.168.0.104/live
// ./streamer rtmp://192.168.0.104/live tnhaoxc.flv
int main(int argc, char **argv)
{
    AVOutputFormat *ofmt = NULL;
    AVFormatContext *ifmt_ctx = NULL, *ofmt_ctx = NULL;
    AVPacket pkt;
    const char *in_filename, *out_filename;
    int ret, i;
    int stream_index = 0;
    int *stream_mapping = NULL;
    int stream_mapping_size = 0;

    if (argc < 3) {
        printf("usage: %s input output\n"
               "API example program to remux a media file with libavformat and libavcodec.\n"
               "The output format is guessed according to the file extension.\n"
               "\n", argv[0]);
        return 1;
    }

    in_filename  = argv[1];
    out_filename = argv[2];

    // 1. 打開輸入
    // 1.1 讀取文件頭,獲取封裝格式相關信息
    if ((ret = avformat_open_input(&ifmt_ctx, in_filename, 0, 0)) < 0) {
        printf("Could not open input file '%s'", in_filename);
        goto end;
    }
    
    // 1.2 解碼一段數據,獲取流相關信息
    if ((ret = avformat_find_stream_info(ifmt_ctx, 0)) < 0) {
        printf("Failed to retrieve input stream information");
        goto end;
    }

    av_dump_format(ifmt_ctx, 0, in_filename, 0);

    // 2. 打開輸出
    // 2.1 分配輸出ctx
    bool push_stream = false;
    char *ofmt_name = NULL;
    if (strstr(out_filename, "rtmp://") != NULL) {
        push_stream = true;
        ofmt_name = "flv";
    }
    else if (strstr(out_filename, "udp://") != NULL) {
        push_stream = true;
        ofmt_name = "mpegts";
    }
    else {
        push_stream = false;
        ofmt_name = NULL;
    }
    avformat_alloc_output_context2(&ofmt_ctx, NULL, ofmt_name, out_filename);
    if (!ofmt_ctx) {
        printf("Could not create output context\n");
        ret = AVERROR_UNKNOWN;
        goto end;
    }

    stream_mapping_size = ifmt_ctx->nb_streams;
    stream_mapping = av_mallocz_array(stream_mapping_size, sizeof(*stream_mapping));
    if (!stream_mapping) {
        ret = AVERROR(ENOMEM);
        goto end;
    }

    ofmt = ofmt_ctx->oformat;

    AVRational frame_rate;
    double duration;

    for (i = 0; i < ifmt_ctx->nb_streams; i++) {
        AVStream *out_stream;
        AVStream *in_stream = ifmt_ctx->streams[i];
        AVCodecParameters *in_codecpar = in_stream->codecpar;

        if (in_codecpar->codec_type != AVMEDIA_TYPE_AUDIO &&
            in_codecpar->codec_type != AVMEDIA_TYPE_VIDEO &&
            in_codecpar->codec_type != AVMEDIA_TYPE_SUBTITLE) {
            stream_mapping[i] = -1;
            continue;
        }

        if (push_stream && (in_codecpar->codec_type == AVMEDIA_TYPE_VIDEO)) {
            frame_rate = av_guess_frame_rate(ifmt_ctx, in_stream, NULL);
            duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
        }

        stream_mapping[i] = stream_index++;

        // 2.2 將一個新流(out_stream)添加到輸出文件(ofmt_ctx)
        out_stream = avformat_new_stream(ofmt_ctx, NULL);
        if (!out_stream) {
            printf("Failed allocating output stream\n");
            ret = AVERROR_UNKNOWN;
            goto end;
        }

        // 2.3 將當前輸入流中的參數拷貝到輸出流中
        ret = avcodec_parameters_copy(out_stream->codecpar, in_codecpar);
        if (ret < 0) {
            printf("Failed to copy codec parameters\n");
            goto end;
        }
        out_stream->codecpar->codec_tag = 0;
    }
    av_dump_format(ofmt_ctx, 0, out_filename, 1);

    if (!(ofmt->flags & AVFMT_NOFILE)) {    // TODO: 研究AVFMT_NOFILE標誌
        // 2.4 建立並初始化一個AVIOContext,用以訪問URL(out_filename)指定的資源
        ret = avio_open(&ofmt_ctx->pb, out_filename, AVIO_FLAG_WRITE);
        if (ret < 0) {
            printf("Could not open output file '%s'", out_filename);
            goto end;
        }
    }

    // 3. 數據處理
    // 3.1 寫輸出文件頭
    ret = avformat_write_header(ofmt_ctx, NULL);
    if (ret < 0) {
        printf("Error occurred when opening output file\n");
        goto end;
    }

    while (1) {
        AVStream *in_stream, *out_stream;

        // 3.2 從輸出流讀取一個packet
        ret = av_read_frame(ifmt_ctx, &pkt);
        if (ret < 0) {
            break;
        }

        in_stream  = ifmt_ctx->streams[pkt.stream_index];
        if (pkt.stream_index >= stream_mapping_size ||
            stream_mapping[pkt.stream_index] < 0) {
            av_packet_unref(&pkt);
            continue;
        }

        int codec_type = in_stream->codecpar->codec_type;
        if (push_stream && (codec_type == AVMEDIA_TYPE_VIDEO)) {
            av_usleep((int64_t)(duration*AV_TIME_BASE));
        }

        pkt.stream_index = stream_mapping[pkt.stream_index];
        out_stream = ofmt_ctx->streams[pkt.stream_index];

        /* copy packet */
        // 3.3 更新packet中的pts和dts
        // 關於AVStream.time_base(容器中的time_base)的說明:
        // 輸入:輸入流中含有time_base,在avformat_find_stream_info()中可取到每一個流中的time_base
        // 輸出:avformat_write_header()會根據輸出的封裝格式肯定每一個流的time_base並寫入文件中
        // AVPacket.pts和AVPacket.dts的單位是AVStream.time_base,不一樣的封裝格式AVStream.time_base不一樣
        // 因此輸出文件中,每一個packet須要根據輸出封裝格式從新計算pts和dts
        av_packet_rescale_ts(&pkt, in_stream->time_base, out_stream->time_base);
        pkt.pos = -1;

        // 3.4 將packet寫入輸出
        ret = av_interleaved_write_frame(ofmt_ctx, &pkt);
        if (ret < 0) {
            printf("Error muxing packet\n");
            break;
        }
        av_packet_unref(&pkt);
    }

    // 3.5 寫輸出文件尾
    av_write_trailer(ofmt_ctx);

end:
    avformat_close_input(&ifmt_ctx);

    /* close output */
    if (ofmt_ctx && !(ofmt->flags & AVFMT_NOFILE)) {
        avio_closep(&ofmt_ctx->pb);
    }
    avformat_free_context(ofmt_ctx);

    av_freep(&stream_mapping);

    if (ret < 0 && ret != AVERROR_EOF) {
        printf("Error occurred: %s\n", av_err2str(ret));
        return 1;
    }

    return 0;
}

2.1 收流

收流功能與打開普通文件代碼沒有區別,打開輸入時,FFmpeg能識別流協議及封裝格式,根據相應的協議層代碼來接收流,收到流數據去掉協議層後獲得的數據和普通文件內容是同樣的一,後續的處理流程也就同樣了。

2.2 推流

推流有兩個須要注意的地方。

一是須要根據輸出流協議顯式指定輸出URL的封裝格式:

bool push_stream = false;
    char *ofmt_name = NULL;
    if (strstr(out_filename, "rtmp://") != NULL) {
        push_stream = true;
        ofmt_name = "flv";
    }
    else if (strstr(out_filename, "udp://") != NULL) {
        push_stream = true;
        ofmt_name = "mpegts";
    }
    else {
        push_stream = false;
        ofmt_name = NULL;
    }
    avformat_alloc_output_context2(&ofmt_ctx, NULL, ofmt_name, out_filename);

這裏只寫了兩種。rtmp推流必須推送flv封裝格式,udp推流必須推送mpegts封裝格式,其餘狀況就看成是輸出普通文件。這裏使用push_stream變量來標誌是否使用推流功能,這個標誌後面會用到。

二是要注意推流的速度,不能一股腦將收到數據全推出去,這樣流媒體服務器承受不住。能夠按視頻播放速度(幀率)來推流。所以每推送一個視頻幀,要延時一個視頻幀的時長。音頻流的數據量很小,能夠沒必要管它,只要按視頻流幀率來推送就好。

在打開輸入URL時,獲取視頻幀的持續時長:

AVRational frame_rate;
    double duration;
    if (push_stream && (in_codecpar->codec_type == AVMEDIA_TYPE_VIDEO)) {
        frame_rate = av_guess_frame_rate(ifmt_ctx, in_stream, NULL);
        duration = (frame_rate.num && frame_rate.den ? av_q2d((AVRational){frame_rate.den, frame_rate.num}) : 0);
    }

av_read_frame()以後,av_interleaved_write_frame()以前增長延時,延時時長就是一個視頻幀的持續時長:

int codec_type = in_stream->codecpar->codec_type;
    if (push_stream && (codec_type == AVMEDIA_TYPE_VIDEO)) {
        av_usleep((int64_t)(duration*AV_TIME_BASE));
    }

3. 驗證

3.1 編譯第三方庫librtmp

FFmpeg默認並不支持rtmp協議。須要先編譯安裝第三方庫librtmp,而後開啓--enable-librtmp選項從新編譯安裝FFmpeg。具體方法參考:「FFmpeg開發環境構建

3.2 搭建流媒體服務器

測試收流與推流功能須要搭建流媒體服務器。咱們選用nginx-rtmp做爲流媒體服務器用於測試。nginx-rtmp服務器運行於虛擬機上,推流客戶端與收流客戶端和nginx-rtmp服務器處於同一局域網便可。個人虛擬機是OPENSUSE LEAP 42.3,IP是192.168.0.104(就是nginx-rtmp服務器的地址)。

爲避免搭建服務器的繁瑣過程,咱們直接使用docker拉取一個nginx-rtmp鏡像。步驟以下:

[1] 安裝與配置docker服務
安裝docker:

sudo zypper install docker

避免每次使用docker時須要添加sudo:將當前用戶添加到docker組,若docker組不存在則建立

sudo gpasswd -a think docker

[2] 配置鏡像加速
docker鏡像源位於美國,摘取鏡像很是緩慢。配置docker中國官方鏡像加速registry.docker-cn.com,可加快鏡像拉取速度。中國官方加速鏡像只包含流行的公有鏡像,私有鏡像仍須要從美國鏡像庫中拉取。

修改 /etc/docker/daemon.json 文件並添加上 registry-mirrors 鍵值:

{
  "registry-mirrors": ["https://registry.docker-cn.com"]
}

修改上述配置文件後重啓docker服務:

systemctl restart docker

[3] 拉取nginx-rtmp鏡像

docker pull tiangolo/nginx-rtmp

[4] 打開容器

docker run -d -p 1935:1935 --name nginx-rtmp tiangolo/nginx-rtmp

[5] 防火牆添加例外端口
若是沒法推流,應在防火牆中將1935端口添加例外,修改/etc/sysconfig/SuSEfirewall2文件,在FW_SERVICES_EXT_TCP項中添加1935端口,以下:
FW_SERVICES_EXT_TCP="ssh 1935"
而後重啓防火牆:
systemctl restart SuSEfirewall2

[6] 驗證服務器
測試文件下載(右鍵另存爲):tnhaoxc.flv

ffmpeg推流測試:

ffmpeg -re -i tnhaoxc.flv -c copy -f flv rtmp://192.168.0.104/live

-re:按視頻幀率的速度讀取輸入
-c copy:輸出流使用和輸入流相同的編解碼器
-f flv:指定輸出流封裝格式爲flv

ffplay拉流播放測試:

ffplay rtmp://192.168.0.104/live

ffplay播放正常,說明nginx-rtmp流媒體服務器搭建成功,可正常使用。

3.3 編譯

在SHELL中運行以下命令下載例程源碼:

svn checkout https://github.com/leichn/exercises/trunk/source/ffmpeg/ffmpeg_stream

在源碼目錄執行./compile.sh命令,生成streamer可執行文件。

3.4 驗證

測試文件下載(右鍵另存爲):shifu.mkv,將測試文件保存在和源碼同一目錄。

推流測試:

./streamer shifu.mkv rtmp://192.168.0.104/live

使用vlc播放器打開網絡串流,輸入流地址「rtmp://192.168.0.104/live」,播放正常。上述測試命令等價於:

ffmpeg -re -i shifu.mkv -c copy -f flv rtmp://192.168.0.104/live

師父

收流測試:先按照上一步命令啓動推流,而後運行以下命令收流

./streamer rtmp://192.168.0.104/live shifu.ts

以上測試命令等價於:

ffmpeg -i rtmp://192.168.0.104/live -c copy shifu.ts

接收結束後檢查一下生成的本地文件shifu.ts可否正常播放。

4. 遺留問題

推流的問題:不論是用ffmpeg命令,仍是用本測試程序,推流結束時會打印以下信息:

[flv @ 0x22ab9c0] Timestamps are unset in a packet for stream 0. This is deprecated and will stop working in the future. Fix your code to set the timestamps properly
Larger timestamp than 24-bit: 0xffffffc2
[flv @ 0x22ab9c0] Failed to update header with correct duration.
[flv @ 0x22ab9c0] Failed to update header with correct filesize.

收流的問題:推流結束後,收流超時未收以數據,會打印以下信息後程序退出運行

RTMP_ReadPacket, failed to read RTMP packet header

5. 參考資料

[1] 雷霄驊, RTMP流媒體技術零基礎學習方法
[2] 觀止雲, 【流媒體|從入門到出家】:流媒體原理(上)
[3] 觀止雲, 【流媒體|從入門到出家】:流媒體原理(下)
[4] 觀止雲, 【流媒體|從入門到出家】:流媒體系統(上)
[5] 觀止雲, 【流媒體|從入門到出家】:流媒體系統(下)
[6] 觀止雲, 總結:從一個直播APP看流媒體系統的應用

6. 修改記錄

2019-03-29 V1.0 初稿

相關文章
相關標籤/搜索