Qt/C++音影片開發80-ffmpeg實現srt推拉流/實時性非常好/音影片同步/支援格式眾多

飞扬青云發表於2024-07-31

一、前言

目前網際網路上的影片直播有兩種,一種是基於RTMP協議的直播,這種直播方式上行推流使用RTMP協議,下行播放使用RTMP,HTTP+FLV或者HLS,直播延時一般大於3秒,廣泛應用秀場、遊戲、賽事和事件直播,滿足了對互動要求不高的場景;另一種是WebRTC協議的直播,這種直播方式使用UDP的協議進行流媒體的分發,直播延時小於1秒,同時連線數一般小於10個,主要應用在視訊通話、秀場連麥等應用場景。除了上述兩種場景外,還有一種影片直播的場景,就是同時要求低延時和大併發的場景,比如賽事直播、股票資訊同步、大班教育等。SRT可以很好地滿足上述場景的要求。

開到這個srt協議,估計很多人和我一樣都有疑問,srt一般叫字幕,之前還有rtp、rtsp、rtmp,這幾年突然多了個srt的,以為他們是親兄弟或者表兄弟。透過查閱相關資料,個人理解的是,srt協議就是udp協議的增強版,之前用udp就可以推拉流,而這個srt也是基於udp,同時增強了機制防止丟包,意味著可以做到低延遲和高併發。其實本人測試下來,在不開啟音影片同步的情況下,rtsp是實時性最好的,開啟同步機制下,srt實時性最好。

用ffmpeg實現srt的推拉流也非常簡單,從ffmpeg5開始支援srt格式,但是測試下來發現效能比較差,從ffmpeg6開始效能比較好,但是ffmpeg6的srt如果開啟的是不存在的srt地址,會崩潰,目前為止測試的ffmpeg6.1還有這個問題,而ffmpeg7沒有這個問題,可能也在不斷的迭代和修復bug。用srt拉流和之前的流程完全一樣,從底層就支援,完全不用變。而srt推流,需要合併到udp推流大類中,格式是mpegts。

二、效果圖


三、體驗地址

  1. 國內站點:https://gitee.com/feiyangqingyun
  2. 國際站點:https://github.com/feiyangqingyun
  3. 個人作品:https://blog.csdn.net/feiyangqingyun/article/details/97565652
  4. 體驗地址:https://pan.baidu.com/s/1d7TH_GEYl5nOecuNlWJJ7g 提取碼:01jf 檔名:bin_video_demo。
  5. 影片主頁:https://space.bilibili.com/687803542

四、功能特點

4.1. 基礎功能

  1. 支援各種音訊影片檔案格式,比如mp3、wav、mp4、asf、rm、rmvb、mkv等。
  2. 支援本地攝像頭裝置和本地桌面採集,支援多裝置和多螢幕。
  3. 支援各種影片流格式,比如rtp、rtsp、rtmp、http、udp等。
  4. 本地音影片檔案和網路音影片檔案,自動識別檔案長度、播放進度、音量大小、靜音狀態等。
  5. 檔案可以指定播放位置、調節音量大小、設定靜音狀態等。
  6. 支援倍速播放檔案,可選0.5倍、1.0倍、2.5倍、5.0倍等速度,相當於慢放和快放。
  7. 支援開始播放、停止播放、暫停播放、繼續播放。
  8. 支援抓拍截圖,可指定檔案路徑,可選抓拍完成是否自動顯示預覽。
  9. 支援錄影儲存,手動開始錄影、停止錄影,部分核心支援暫停錄影後繼續錄影,跳過不需要錄影的部分。
  10. 支援無感知切換迴圈播放、自動重連等機制。
  11. 提供播放成功、播放完成、收到解碼圖片、收到抓拍圖片、影片尺寸變化、錄影狀態變化等訊號。
  12. 多執行緒處理,一個解碼一個執行緒,不卡主介面。

4.2. 特色功能

  1. 同時支援多種解碼核心,包括qmedia核心(Qt4/Qt5/Qt6)、ffmpeg核心(ffmpeg2/ffmpeg3/ffmpeg4/ffmpeg5/ffmpeg6)、vlc核心(vlc2/vlc3)、mpv核心(mpv1/mp2)、mdk核心、海康sdk、easyplayer核心等。
  2. 非常完善的多重基類設計,新增一種解碼核心只需要實現極少的程式碼量,就可以應用整套機制,極易擴充。
  3. 同時支援多種畫面顯示策略,自動調整(原始解析度小於顯示控制元件尺寸則按照原始解析度大小顯示,否則等比縮放)、等比縮放(永遠等比縮放)、拉伸填充(永遠拉伸填充)。所有核心和所有影片顯示模式下都支援三種畫面顯示策略。
  4. 同時支援多種影片顯示模式,控制代碼模式(傳入控制元件控制代碼交給對方繪製控制)、繪製模式(回撥拿到資料後轉成QImage用QPainter繪製)、GPU模式(回撥拿到資料後轉成yuv用QOpenglWidget繪製)。
  5. 支援多種硬體加速型別,ffmpeg可選dxva2、d3d11va等,vlc可選any、dxva2、d3d11va,mpv可選auto、dxva2、d3d11va,mdk可選dxva2、d3d11va、cuda、mft等。不同的系統環境有不同的型別選擇,比如linux系統有vaapi、vdpau,macos系統有videotoolbox。
  6. 解碼執行緒和顯示窗體分離,可指定任意解碼核心掛載到任意顯示窗體,動態切換。
  7. 支援共享解碼執行緒,預設開啟並且自動處理,當識別到相同的影片地址,共享一個解碼執行緒,在網路影片環境中可以大大節約網路流量以及對方裝置的推流壓力。國內頂尖影片廠商均採用此策略。這樣只要拉一路影片流就可以共享到幾十個幾百個通道展示。
  8. 自動識別影片旋轉角度並繪製,比如手機上拍攝的影片一般是旋轉了90度的,播放的時候要自動旋轉處理,不然預設是倒著的。
  9. 自動識別影片流播放過程中解析度的變化,在影片控制元件上自動調整尺寸。比如攝像機可以在使用過程中動態配置解析度,當解析度改動後對應影片控制元件也要做出同步反應。
  10. 音影片檔案無感知自動切換迴圈播放,不會出現切換期間黑屏等肉眼可見的切換痕跡。
  11. 影片控制元件同時支援任意解碼核心、任意畫面顯示策略、任意影片顯示模式。
  12. 影片控制元件懸浮條同時支援控制代碼、繪製、GPU三種模式,非絕對座標移來移去。
  13. 本地攝像頭裝置支援指定裝置名稱、解析度、幀率進行播放。
  14. 本地桌面採集支援設定採集區域、偏移值、指定桌面索引、幀率、多個桌面同時採集等。還支援指定視窗標題採集固定視窗。
  15. 錄影檔案同時支援開啟的影片檔案、本地攝像頭、本地桌面、網路影片流等。
  16. 瞬間響應開啟和關閉,無論是開啟不存在的影片或者網路流,探測裝置是否存在,讀取中的超時等待,收到關閉指令立即中斷之前的操作並響應。
  17. 支援開啟各種圖片檔案,支援本地音影片檔案拖曳播放。
  18. 影片流通訊方式可選tcp/udp,有些裝置可能只提供了某一種協議通訊比如tcp,需要指定該種協議方式開啟。
  19. 可設定連線超時時間(影片流探測用的超時時間)、讀取超時時間(採集過程中的超時時間)。
  20. 支援逐幀播放,提供上一幀/下一幀函式介面,可以逐幀查閱採集到的影像。
  21. 音訊檔案自動提取專輯資訊比如標題、藝術家、專輯、專輯封面,自動顯示專輯封面。
  22. 影片響應極低延遲0.2s左右,極速響應開啟影片流0.5s左右,專門做了最佳化處理。
  23. 支援H264/H265編碼(現在越來越多的監控攝像頭是H265影片流格式)生成影片檔案,內部自動識別切換編碼格式。
  24. 支援使用者資訊中包含特殊字元(比如使用者資訊中包含+#@等字元)的影片流播放,內建解析轉義處理。
  25. 支援濾鏡,各種水印及圖形效果,支援多個水印和影像,可以將OSD標籤資訊和各種圖形資訊寫入到MP4檔案。
  26. 支援影片流中的各種音訊格式,AAC、PCM、G.726、G.711A、G.711Mu、G.711ulaw、G.711alaw、MP2L2等都支援,推薦選擇AAC相容性跨平臺性最好。
  27. 核心ffmpeg採用純qt+ffmpeg解碼,非sdl等第三方繪製播放依賴,gpu繪製採用qopenglwidget,音訊播放採用qaudiooutput。
  28. 核心ffmpeg和核心mdk支援安卓,其中mdk支援安卓硬解碼,效能非常兇殘。
  29. 可以切換音影片軌道,也就是節目通道,可能ts檔案帶了多個音影片節目流,可以分別設定要播放哪一個,可以播放前設定好和播放過程中動態設定。
  30. 可以設定影片旋轉角度,可以播放前設定好和播放過程中動態改變。
  31. 影片控制元件懸浮條自帶開始和停止錄影切換、聲音靜音切換、抓拍截圖、關閉影片等功能。
  32. 音訊元件支援聲音波形值資料解析,可以根據該值繪製波形曲線和柱狀聲音條,預設提供了聲音振幅訊號。
  33. 標籤和圖形資訊支援三種繪製方式,繪製到遮罩層、繪製到圖片、源頭繪製(對應資訊可以儲存到檔案)。
  34. 透過傳入一個url地址,該地址可以帶上通訊協議、解析度、幀率等資訊,無需其他設定。
  35. 儲存影片到檔案支援三種策略,自動處理、僅限檔案、全部轉碼,轉碼策略支援自動識別、轉264、轉265,編碼儲存支援指定解析度縮放或者等比例縮放。比如對儲存檔案體積有要求可以指定縮放後再儲存。
  36. 支援加密儲存檔案和解密播放檔案,可以指定秘鑰文字。
  37. 提供的監控佈局類支援64通道同時顯示,還支援各種異型佈局,比如13通道,手機上6行2列布局。各種佈局可以自由定義。
  38. 支援電子放大,在懸浮條切換到電子放大模式,在畫面上選擇需要放大的區域,選取完畢後自動放大,再次切換放大模式可以復位。
  39. 各元件中極其詳細的列印資訊提示,尤其是報錯資訊提示,封裝的統一列印格式。針對現場複雜的裝置環境測試極其方便有用,相當於精確定位到具體哪個通道哪個步驟出錯。
  40. 同時提供了簡單示例、影片播放器、多畫面影片監控、監控回放、逐幀播放、多屏渲染等單獨窗體示例,專門演示對應功能如何使用。
  41. 監控回放可選不同廠家型別、回放時間段、使用者資訊、指定通道。支援切換回放進度。
  42. 可以從音效卡裝置下拉框選擇音效卡播放聲音,提供對應的切換音效卡函式介面。
  43. 支援編譯到手機app使用,提供了專門的手機app佈局介面,可以作為手機上的影片監控使用。
  44. 程式碼框架和結構最佳化到最優,效能強悍,註釋詳細,持續迭代更新升級。
  45. 原始碼支援windows、linux、mac、android等,支援各種國產linux系統,包括但不限於統信UOS/中標麒麟/銀河麒麟等。還支援嵌入式linux。
  46. 原始碼支援Qt4、Qt5、Qt6,相容所有版本。

4.3. 影片控制元件

  1. 可動態新增任意多個osd標籤資訊,標籤資訊包括名字、是否可見、字號大小、文字文字、文字顏色、背景顏色、標籤圖片、標籤座標、標籤格式(文字、日期、時間、日期時間、圖片)、標籤位置(左上角、左下角、右上角、右下角、居中、自定義座標)。
  2. 可動態新增任意多個圖形資訊,這個非常有用,比如人工智慧演算法解析後的圖形區域資訊直接發給影片控制元件即可。圖形資訊支援任意形狀,直接繪製在原始圖片上,採用絕對座標。
  3. 圖形資訊包括名字、邊框大小、邊框顏色、背景顏色、矩形區域、路徑集合、點座標集合等。
  4. 每個圖形資訊都可指定三種區域中的一種或者多種,指定了的都會繪製。
  5. 內建懸浮條控制元件,懸浮條位置支援頂部、底部、左側、右側。
  6. 懸浮條控制元件引數包括邊距、間距、背景透明度、背景顏色、文字顏色、按下顏色、位置、按鈕圖示程式碼集合、按鈕名稱標識集合、按鈕提示資訊集合。
  7. 懸浮條控制元件一排工具按鈕可自定義,透過結構體引數設定,圖示可選圖形字型還是自定義圖片。
  8. 懸浮條按鈕內部實現了錄影切換、抓拍截圖、靜音切換、關閉影片等功能,也可以自行在原始碼中增加自己對應的功能。
  9. 懸浮條按鈕對應實現了功能的按鈕,有對應圖示切換處理,比如錄影按鈕按下後會切換到正在錄影中的圖示,聲音按鈕切換後變成靜音圖示,再次切換還原。
  10. 懸浮條按鈕單擊後都用名稱唯一標識作為訊號發出,可以自行關聯響應處理。
  11. 懸浮條空白區域可以顯示提示資訊,預設顯示當前影片解析度大小,可以增加幀率、碼流大小等資訊。
  12. 影片控制元件引數包括邊框大小、邊框顏色、焦點顏色、背景顏色(預設透明)、文字顏色(預設全域性文字顏色)、填充顏色(影片外的空白處填充黑色)、背景文字、背景圖片(如果設定了圖片優先取圖片)、是否複製圖片、縮放顯示模式(自動調整、等比縮放、拉伸填充)、影片顯示模式(控制代碼、繪製、GPU)、啟用懸浮條、懸浮條尺寸(橫向為高度、縱向為寬度)、懸浮條位置(頂部、底部、左側、右側)。

五、相關程式碼

const char *FFmpegSaveHelper::getFormat(const QString &url, bool mov)
{
    //預設是mp4/mov更具相容性比如音訊支援pcma等
    const char *format = mov ? "mov" : "mp4";
    if (url.startsWith("rtmp://") || url.startsWith("rtmps://")) {
        format = "flv";
    } else if (url.startsWith("rtsp://") || url.startsWith("rtsps://")) {
        format = "rtsp";
    } else if (url.startsWith("srt://") || url.startsWith("udp://")) {
        format = "mpegts";
    }

    return format;
}

const char *FFmpegSaveHelper::getFormat(AVDictionary **options, QString &url, bool mov, const QString &flag)
{
    const char *format = FFmpegSaveHelper::getFormat(url, mov);
    if (format == "mov" || format == "mp4") {
        QByteArray temp;
        if (!flag.isEmpty()) {
            temp = flag.toUtf8();
            format = temp.constData();
            QString suffix = url.split(".").last();
            url.replace(suffix, flag);
        }
    } else if (format == "rtsp") {
        av_dict_set(options, "stimeout", "3000000", 0);
        av_dict_set(options, "rtsp_transport", "tcp", 0);
    }

    return format;
}

bool FFmpegSave::initStream()
{
    //如果存在秘鑰則啟用加密
    AVDictionary *options = NULL;
    FFmpegHelper::initEncryption(&options, this->property("cryptoKey").toByteArray());

    QString flag;
    if (getOnlySaveAudio() && encodeAudio != EncodeAudio_Aac) {
        flag = "wav";
    }

    //既可以是儲存到檔案也可以是推流(對應格式要區分)
    bool mov = audioCodecName.startsWith("pcm_");
    const char *format = FFmpegSaveHelper::getFormat(&options, fileName, mov, flag);

    //開闢一個格式上下文用來處理影片流輸出(末尾url不填則rtsp推流失敗)
    QByteArray fileData = fileName.toUtf8();
    const char *url = fileData.data();
    int result = avformat_alloc_output_context2(&formatCtx, NULL, format, url);
    if (result < 0) {
        debug(result, "建立格式", "");
        return false;
    }

    //建立輸出影片流
    if (!this->initVideoStream()) {
        goto end;
    }

    //建立輸出音訊流
    if (!this->initAudioStream()) {
        goto end;
    }

    //開啟輸出檔案/當儲存到檔案的時候需要執行/推流不需要
    if (!(formatCtx->oformat->flags & AVFMT_NOFILE)) {
        //記錄開始時間並設定回撥用於超時判斷
        startTime = av_gettime();
        formatCtx->interrupt_callback.callback = FFmpegSaveHelper::openAndWriteCallBack;
        formatCtx->interrupt_callback.opaque = this;

        tryOpen = true;
        result = avio_open2(&formatCtx->pb, url, AVIO_FLAG_WRITE, &formatCtx->interrupt_callback, NULL);
        tryOpen = false;
        if (result < 0) {
            debug(result, "開啟輸出", "");
            goto end;
        }
    }

    //寫入檔案開始符
    result = avformat_write_header(formatCtx, &options);
    if (result < 0) {
        debug(result, "寫檔案頭", "");
        goto end;
    }

    writeHeader = true;
    debug(0, "開啟輸出", QString("格式: %1").arg(format));
    return true;

end:
    //關閉釋放並清理檔案
    this->close();
    this->deleteFile(fileName);
    return false;
}

bool FFmpegSave::initVideoStream()
{
    if (needVideo) {
        videoIndexOut = 0;
        AVStream *stream = avformat_new_stream(formatCtx, NULL);
        if (!stream) {
            return false;
        }

        //設定旋轉角度(沒有編碼的資料是源頭帶有旋轉角度的/編碼後的是正常旋轉好的)
        if (!videoEncode) {
            FFmpegHelper::setRotate(stream, rotate);
        }

        //複製解碼器上下文引數(不編碼從源頭流複製/編碼從設定的編碼器複製)
        int result = -1;
        if (videoEncode) {
            stream->r_frame_rate = videoCodecCtx->framerate;
            result = FFmpegHelper::copyContext(videoCodecCtx, stream, true);
        } else {
            result = FFmpegHelper::copyContext(videoStreamIn, stream);
        }

        if (result < 0) {
            debug(result, "複製引數", "");
            return false;
        }
    }

    return true;
}

bool FFmpegSave::initAudioStream()
{
    if (needAudio) {
        audioIndexOut = (videoIndexOut == 0 ? 1 : 0);
        AVStream *stream = avformat_new_stream(formatCtx, NULL);
        if (!stream) {
            return false;
        }

        //複製解碼器上下文引數(不編碼從源頭流複製/編碼從設定的編碼器複製)
        int result = -1;
        if (audioEncode) {
            result = FFmpegHelper::copyContext(audioCodecCtx, stream, true);
        } else {
            result = FFmpegHelper::copyContext(audioStreamIn, stream);
        }

        if (result < 0) {
            debug(result, "複製引數", "");
            return false;
        }
    }

    return true;
}

bool FFmpegSave::init()
{
    //必須存在輸入視音訊流物件其中一個
    if (fileName.isEmpty() || (!videoStreamIn && !audioStreamIn)) {
        return false;
    }

    //檢查推流地址是否正常/udp不需要檢測
    if (saveMode != SaveMode_File && saveMode != SaveMode_Srt && saveMode != SaveMode_Udp && !UrlHelper::checkUrl(fileName, 1000)) {
        debug(0, "地址不通", "");
        if (!this->isRunning()) {
            this->start();
        }
        return false;
    }

    //獲取媒體資訊
    this->getMediaInfo();
    //檢查編碼處理
    this->checkEncode();

    //沒有啟用視音訊則不用繼續
    if (!needVideo && !needAudio) {
        debug(0, "無需處理", "原因: 沒有啟用音影片");
        return false;
    }

    //ffmpeg2不支援重新編碼的推流
#if (FFMPEG_VERSION_MAJOR < 3)
    if (saveMode != SaveMode_File && (videoEncode || audioEncode)) {
        return false;
    }
#endif

    //初始化對應視音訊編碼器
    if (!this->initVideoCtx()) {
        return false;
    }
    if (!this->initAudioCtx()) {
        return false;
    }

    //設定了需要封裝格式並且沒有重新編碼則需要加上/儲存檔案才需要/推流不需要
    if (mp4ToAnnexB && !videoEncode && needVideo && saveMode == SaveMode_File) {
        FFmpegSaveHelper::initBsfCtx(videoStreamIn, &bsfCtx, videoCodecName == "h264");
        debug(0, "封裝格式", QString("格式: %1").arg(videoCodecName));
    }

    //儲存264資料直接寫檔案/為什麼放在這裡而不是最前面/因為有些也需要編碼成正規的264
    if (saveVideoType == SaveVideoType_Stream) {
        return true;
    }

    //初始化視音訊流
    if (!this->initStream()) {
        return false;
    }

    debug(0, "索引資訊", QString("影片: %1/%2 音訊: %3/%4").arg(videoIndexIn).arg(videoIndexOut).arg(audioIndexIn).arg(audioIndexOut));
    return true;
}

相關文章