Qt邊推流邊錄製/實時性好延遲低/16路1080P推流加錄製只佔1%CPU/最佳化到極致

飞扬青云發表於2024-11-11

一、前言

這個一邊推流一邊錄製的功能,有很多使用者提到過,之前因為時間的原因,一直沒有搞,年初的時候索性抽空搞了下,也著實費了些功夫。推流用的是ffmpeg這個開源的牛逼的第三方庫,搞音影片開發的人應該沒人不認識這個庫,養活了很多程式設計師以及廠家,甚至不乏一些大廠,如果能把ffmpeg搞精通,在國內拿個30K以上毫無壓力分分鐘的事情。但是這個庫也確實有一定的難度,起碼對我來說還是挺難的,按照提供的示例做點簡單的demo沒有問題,要搞穩定搞相容性以及相容各種需求場景,就非常難了,沒有個好幾年的搗鼓,很難搞好。

推流的前提是拉流,之前已經用ffmpeg做了拉流和儲存,既可以儲存到本地影片檔案,也可以儲存到rtsp/rtmp這種地址,儲存到流地址其實就是推流,以前沒搞過的時候還以為多複雜,原來就是儲存檔案改個地址,總共就改動幾行程式碼就行。既然已經可以推流和儲存,那說明一邊推流一邊錄製也是可行的。為了追求最簡方式實現,透過在原來的基礎上,增加一個訊號,也就是在儲存的時候發出去一個avpacket的包訊號,這個包是最終要寫入到檔案的資料包,如果源頭就是264/265這種格式的資料,這個包可以直接存入檔案即可。於是一邊推一邊存的功能,就是重新new一個ffmpegsave類,將之前推流的類發出來的收到資料包訊號,發給儲存類的槽即可,非常簡單方便易用。打完收工完美實現,又是去沙縣加雞腿的一天。

二、效果圖

三、體驗地址

  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_push。
  5. 影片主頁:https://space.bilibili.com/687803542

四、功能特點

  1. 支援各種本地音影片檔案和網路音影片檔案,格式包括mp3、aac、wav、wma、mp4、mkv、rmvb、wmv、mpg、flv、asf等。
  2. 支援各種網路音影片流,網路攝像頭,協議包括rtsp、rtmp、http等。
  3. 支援本地攝像頭裝置推流,可指定解析度、幀率、格式等。
  4. 支援本地桌面採集推流,可指定螢幕索引、採集區域、起始座標、幀率等,也支援指定視窗標題進行採集。
  5. 可實時切換預覽影片檔案,可切換音影片檔案播放進度,切換到哪裡就推流到哪裡。預覽過程中可以切換靜音狀態和暫停推流。
  6. 可指定重新編碼推流,任意源頭格式可選強轉264或265格式。
  7. 可轉換解析度推流,設定等比例縮放或者指定解析度進行轉換。
  8. 推流的清晰度、質量、位元速率都可調,可以節約網路頻寬和拉流端的壓力。
  9. 音影片檔案自動迴圈不間斷推流。
  10. 音影片流有自動掉線重連機制,重連成功自動繼續推流。
  11. 支援各種流媒體服務程式,包括但不限於mediamtx、ZLMediaKit、srs、LiveQing、nginx-rtmp、EasyDarwin、ABLMediaServer。
  12. 透過配置檔案自動載入對應流媒體程式的協議和埠,自動生成推流地址和各種協議的拉流地址。可以透過配置檔案自己增加流媒體程式。
  13. 可選rtmp、rtmp格式推流,推流成功後,支援多種格式拉流,包括但不限於rtsp、rtmp、hls、flv、ws-flv、webrtc等。
  14. 在軟體上推流成功後,可以直接單擊網頁預覽,實時預覽推流後拉流的畫面,多畫面網頁展示。
  15. 軟體介面上可單擊對應按鈕,動態新增檔案和目錄,可手動輸入地址。
  16. 推拉流實時性極高,延遲極低,延遲時間大概在100ms左右。
  17. 極低CPU資源佔用,4路主碼流推流只需要佔用0.2%CPU。理論上常規普通PC機器推100路毫無壓力,主要效能瓶頸在網路。
  18. 可以推流到外網伺服器,然後透過手機、電腦、平板等裝置播放對應的影片流。
  19. 每路推流都可以手動指定唯一識別符號(方便拉流/使用者無需記憶複雜的地址),沒有指定則按照策略隨機生成hash值。也支援自動按照指定標識後面加數字的方式遞增命名。比如設定標識為字母v,策略為標識遞增,則每新增一個對應的推流碼命名依次是v1、v2、v3等。
  20. 根據推流協議自動轉碼格式,預設策略按照選擇的推流協議,比如rtsp支援265而rtmp不支援,如果是265的檔案而選擇rtmp推流,則自動轉碼成264格式再推流。
  21. 音影片同步推流,在拉流和採集的時候就會自動處理好同步,同步後的資料再推流。
  22. 表格中實時顯示每一路推流的解析度和音影片資料狀態,灰色表示沒有輸入流,黑色表示沒有輸出流,綠色表示原資料推流,紅色表示轉碼後的資料推流。
  23. 自動重連影片源,自動重連流媒體伺服器,保證啟動後,推流地址和開啟地址都實時重連,只要恢復後立即連上繼續採集和推流。
  24. 根據不同的流媒體伺服器型別,自動生成對應的rtsp、rtmp、hls、flv、ws-flv、webrtc拉流地址,使用者可以直接複製該地址到播放器或者網頁中預覽檢視。
  25. 新增的推流地址等資訊自動儲存到檔案,可以手動開啟進行修改,預設啟動後自動載入歷史記錄。
  26. 可以指定生成的網頁檔案儲存位置,方便作為網站網頁釋出,可以直接在瀏覽器中輸入網址進行訪問,釋出後可以直接在區域網其他裝置比如手機或者電腦開啟對應網址訪問。
  27. 可選是否開機啟動、後臺執行等。網路推流新增的rtsp地址可勾選是否隱藏地址中的使用者資訊。
  28. 自帶裝置推流模組,自動識別本地裝置,包括本地的攝像頭和桌面,可以手動選擇不同的是影片和音訊採集裝置進行推流。
  29. 自帶檔案點播模組,新增檔案後使用者可以拉取地址點播,使用者端可以任意切換播放進度。支援各種瀏覽器(谷歌chromium、微軟edge、火狐firefox等)、各種播放器(vlc、mpv、ffplay、potplayer、mpchc等)開啟請求。
  30. 檔案點播模組實時統計顯示每個檔案對應的訪問數量、總訪問數量、不同IP地址訪問數量。
  31. 檔案點播模組採用純QTcpSocket通訊,不依賴流媒體服務程式,核心原始碼不到500行,註釋詳細,功能完整。
  32. 支援任意Qt版本(Qt4、Qt5、Qt6),支援任意系統(windows、linux、macos、android、嵌入式linux等)。

五、相關程式碼

#include "netpushclient.h"
#include "ffmpegthread.h"
#include "ffmpegsave.h"
#include "videohelper.h"
#include "osdgraph.h"

bool NetPushClient::checkB = false;
bool NetPushClient::recordInteger = false;
int NetPushClient::recordDuration = 0;
int NetPushClient::encodeVideo = 0;
float NetPushClient::encodeVideoRatio = 1;
QString NetPushClient::encodeVideoScale = "1";

NetPushClient::NetPushClient(QObject *parent) : QObject(parent)
{
    ffmpegThread = NULL;
    ffmpegSave = NULL;

    //定時器控制多久錄製一個檔案
    timerRecord = new QTimer(this);
    timerRecord->setInterval(1000);
    connect(timerRecord, SIGNAL(timeout()), this, SLOT(checkRecord()));
}

NetPushClient::~NetPushClient()
{
    this->stop();
}

QString NetPushClient::getMediaUrl()
{
    return this->mediaUrl;
}

QString NetPushClient::getPushUrl()
{
    return this->pushUrl;
}

FFmpegThread *NetPushClient::getVideoThread()
{
    return this->ffmpegThread;
}

void NetPushClient::checkRecord()
{
    //0. 時長單位分鐘/觸發條件自動重新錄影/recordDuration=0/表示禁用錄影
    //1. recordInteger引數控制是否整數倍數錄影/recordDuration引數控制錄製檔案時長/整數倍錄影下時長為對應的模數
    //2. 一般監控行業會按照整點錄影/比如30分鐘60分鐘一個影片檔案/這樣錄製的檔案起始時間和結束時間整整齊齊
    //3. 整點錄影情況下除了第一個和最後一個錄影檔案可能時長不一樣/中間的檔案肯定時長都一樣
    //4. 非整點錄影就按照錄影總時長計時/所有儲存的檔案都是按照時長儲存的

    //5. recordInteger=true/recordDuration=5/表示每到5分鐘的時候錄製一個檔案
    //6. 上面錄製結果: 11:01開始錄製/11:05結束上一個錄製並重新錄製/第一個檔案時長4分鐘
    //7. recordInteger=false/recordDuration=5/表示每過5分鐘的時候錄製一個檔案
    //8. 上面錄製結果: 11:01開始錄製/11:06結束上一個錄製並重新錄製/第一個檔案時長5分鐘

    bool ok = false;
    QDateTime now = QDateTime::currentDateTime();
    qint64 offset = recordTime.msecsTo(now);
    if (recordInteger && recordDuration > 1) {
        QTime time = now.time();
        int min = time.minute();
        int sec = time.second();
        min = (min == 0 ? 60 : min);
        ok = ((min % recordDuration == 0) && sec >= 0 && sec <= 2);
        //qDebug() << TIMEMS << min << sec << (min % recordDuration == 0) << offset << ok;
    } else {
        ok = (offset >= (recordDuration * 60 * 1000));
        //qDebug() << TIMEMS << recordDuration << offset << ok;
    }

    if (ok && offset >= 5000) {
        this->record();
    }
}

void NetPushClient::record()
{
    if (ffmpegSave) {
        //取出推流碼
        QString flag = pushUrl.split("/").last();
        //檔名不能包含特殊字元/需要替換成固定字母
        QString pattern("[\\\\/:|*?\"<>]|[cC][oO][mM][1-9]|[lL][pP][tT][1-9]|[cC][oO][nM]|[pP][rR][nN]|[aA][uU][xX]|[nN][uU][lL]");
#if (QT_VERSION >= QT_VERSION_CHECK(6,0,0))
        QRegularExpression rx(pattern);
#else
        QRegExp rx(pattern);
#endif
        flag.replace(rx, "X");

        //檔名加上時間結尾
        QString path = QString("%1/video/%2").arg(qApp->applicationDirPath()).arg(QDATE);
        QString name = QString("%1/%2_%3.mp4").arg(path).arg(flag).arg(STRDATETIME);

        //目錄不存在則新建
        QDir dir(path);
        if (!dir.exists()) {
            dir.mkpath(path);
        }

        //先停止再開啟重新錄製
        ffmpegSave->stop();
        ffmpegSave->open(name);
        recordTime = QDateTime::currentDateTime();
    }
}

void NetPushClient::receivePlayStart(int time)
{
    //演示新增OSD後推流
#ifdef betaversion
    int height = ffmpegThread->getVideoHeight();
    QList<OsdInfo> osds = OsdGraph::getTestOsd(height);
    ffmpegThread->setOsdInfo(osds);
#endif

    //開啟後才能啟動錄影
    ffmpegThread->recordStart(pushUrl);

    //推流以外還單獨儲存
    if (!ffmpegSave && recordDuration > 0) {
        //源頭儲存沒成功就不用繼續
        FFmpegSave *saveFile = ffmpegThread->getSaveFile();
        if (!saveFile->getIsOk()) {
            return;
        }

        ffmpegSave = new FFmpegSave(this);
        //重新編碼過的則取影片儲存類的物件
        AVStream *videoStreamIn = saveFile->getVideoEncode() ? saveFile->getVideoStream() : ffmpegThread->getVideoStream();
        AVStream *audioStreamIn = saveFile->getAudioEncode() ? saveFile->getAudioStream() : ffmpegThread->getAudioStream();
        ffmpegSave->setSavePara(ffmpegThread->getMediaType(), SaveVideoType_Mp4, videoStreamIn, audioStreamIn);
        this->record();
        timerRecord->start();
    }
}

void NetPushClient::receivePacket(AVPacket *packet)
{
    if (ffmpegSave && ffmpegSave->getIsOk()) {
        ffmpegSave->writePacket2(packet);
    }

    FFmpegHelper::freePacket(packet);
}

void NetPushClient::recorderStateChanged(const RecorderState &state, const QString &file)
{
    int width = 0;
    int height = 0;
    int videoStatus = 0;
    int audioStatus = 0;
    if (ffmpegThread) {
        width = ffmpegThread->getVideoWidth();
        height = ffmpegThread->getVideoHeight();
        FFmpegSave *saveFile = ffmpegThread->getSaveFile();
        if (saveFile->getIsOk()) {
            if (saveFile->getVideoIndexIn() >= 0) {
                if (saveFile->getVideoIndexOut() >= 0) {
                    videoStatus = (saveFile->getVideoEncode() ? 3 : 2);
                } else {
                    videoStatus = 1;
                }
            }
            if (saveFile->getAudioIndexIn() >= 0) {
                if (saveFile->getAudioIndexOut() >= 0) {
                    audioStatus = (saveFile->getAudioEncode() ? 3 : 2);
                } else {
                    audioStatus = 1;
                }
            }
        }
    }

    //只有處於錄製中才表示正常推流開始
    bool start = (state == RecorderState_Recording);
    emit pushStart(mediaUrl, width, height, videoStatus, audioStatus, start);
}

void NetPushClient::receiveSaveStart()
{
    emit pushChanged(mediaUrl, 0);
}

void NetPushClient::receiveSaveFinsh()
{
    emit pushChanged(mediaUrl, 1);
}

void NetPushClient::receiveSaveError(int error)
{
    emit pushChanged(mediaUrl, 2);
}

void NetPushClient::setMediaUrl(const QString &mediaUrl)
{
    this->mediaUrl = mediaUrl;
}

void NetPushClient::setPushUrl(const QString &pushUrl)
{
    this->pushUrl = pushUrl;
}

void NetPushClient::start()
{
    if (ffmpegThread || mediaUrl.isEmpty() || pushUrl.isEmpty()) {
        return;
    }

    //例項化影片採集執行緒
    ffmpegThread = new FFmpegThread;
    //關聯播放開始訊號用來啟動推流
    connect(ffmpegThread, SIGNAL(receivePlayStart(int)), this, SLOT(receivePlayStart(int)));
    //關聯錄製訊號變化用來判斷是否推流成功
    connect(ffmpegThread, SIGNAL(recorderStateChanged(RecorderState, QString)), this, SLOT(recorderStateChanged(RecorderState, QString)));
    //設定播放地址
    ffmpegThread->setMediaUrl(mediaUrl);

    //設定影片模式
#ifdef openglx
    ffmpegThread->setVideoMode(VideoMode_Opengl);
#else
    ffmpegThread->setVideoMode(VideoMode_Painter);
#endif

    //設定通訊協議(如果是rtsp影片流建議設定tcp)
    //ffmpegThread->setTransport("tcp");
    //設定硬解碼(和推流無關/只是為了加速顯示/推流只和硬編碼有關)
    //ffmpegThread->setHardware("dxva2");
    //設定快取大小(如果解析度幀率碼流很大需要自行加大快取)
    ffmpegThread->setCaching(8192000);
    //設定解碼策略(推流的地址再拉流建議開啟最快速度)
    //ffmpegThread->setDecodeType(DecodeType_Fastest);

    //設定讀取超時時間超時後會自動重連
    ffmpegThread->setReadTimeout(10 * 1000);
    //設定連線超時時間(0表示一直連)
    ffmpegThread->setConnectTimeout(0);
    //設定重複播放相當於迴圈推流
    ffmpegThread->setPlayRepeat(true);
    //設定預設不播放音訊(介面上切換到哪一路就開啟)
    ffmpegThread->setPlayAudio(false);
    //設定預設不預覽影片(介面上切換到哪一路就開啟)
    ffmpegThread->setPushPreview(false);

    //設定儲存影片類將資料包訊號發出來用於儲存檔案
    FFmpegSave *saveFile = ffmpegThread->getSaveFile();
    saveFile->setProperty("checkB", checkB);
    saveFile->setSendPacket(recordDuration > 0, false);
    connect(saveFile, SIGNAL(receivePacket(AVPacket *)), this, SLOT(receivePacket(AVPacket *)));
    connect(saveFile, SIGNAL(receiveSaveStart()), this, SLOT(receiveSaveStart()));
    connect(saveFile, SIGNAL(receiveSaveFinsh()), this, SLOT(receiveSaveFinsh()));
    connect(saveFile, SIGNAL(receiveSaveError(int)), this, SLOT(receiveSaveError(int)));

    //如果是本地裝置或者桌面錄屏要取出其他引數
    VideoHelper::initVideoPara(ffmpegThread, mediaUrl, encodeVideoRatio, encodeVideoScale);

    //設定影片編碼格式/影片壓縮比率/影片縮放比例
    ffmpegThread->setEncodeVideo((EncodeVideo)encodeVideo);
    ffmpegThread->setEncodeVideoRatio(encodeVideoRatio);
    ffmpegThread->setEncodeVideoScale(encodeVideoScale);

    //啟動播放
    ffmpegThread->play();
}

void NetPushClient::stop()
{
    //停止推流和採集並徹底釋放物件
    if (ffmpegThread) {
        ffmpegThread->recordStop();
        ffmpegThread->stop();
        ffmpegThread->deleteLater();
        ffmpegThread = NULL;
    }

    //停止錄製
    if (ffmpegSave) {
        timerRecord->stop();
        ffmpegSave->stop();
        ffmpegSave->deleteLater();
        ffmpegSave = NULL;
    }
}

相關文章