Windows遠程桌面實現之五(FFMPEG實現桌面屏幕RTSP,RTMP推流及本地保存)

                                                                                                   by fanxiushu 2018-07-10 轉載或引用請註明原始作者。
前面文章分別闡述了,如何抓取電腦屏幕數據,如何採集電腦聲音,
如何實現在現代瀏覽器中通過HTML5和WebSocket直接進行遠程控制。

這章闡述如何把採集到的電腦屏幕和電腦聲音,通過一些通用協議,
比如RTSP,RTMP把電腦桌面屏幕推送到更廣泛的直播服務器上,達到電腦屏幕直播的效果。
或者把電腦屏幕保存成本地的MP4或MKV視頻文件。

其實 https://github.com/fanxiushu/xdisp_virthttps://download.csdn.net/download/fanxiushu/10168823 提供的程序,
其中xdisp_server.exe中轉服務器,本身就是個直播服務器的效果。
只不過xdisp_server.exe不但能直播屏幕和聲音,還能遠程控制,實時性要求也更高。
還能直接在網頁中播放,只是使用的是自己定義的私有通訊協議。

爲了把我們的電腦屏幕無私的共享出去,而且讓別人更好的中轉,加速,就得使用一些比較廣泛使用的協議,
比如RTSP, 這個一般在視頻監控領域用得比較多,實時性要求也比較高,不過我在內網測試下來,總有幾秒的延遲。
播放器使用VLC, RTSP是在linux上架設的 easydarwin 測試服務器,當然也就是隨便架設,沒做什麼優化。
也可能播放的屏幕尺寸太大,是 2560X1600 的視頻尺寸。
另一個RTMP協議在當前互聯網直播推流中用得很多,linux我使用的是nginx+rtmp_module,這個的延時就更加糟糕,
最差能達到十幾秒的延遲。而且是越播放越往後延遲。當然都是屬於測試性質的架設這些服務器,主要是爲了測試開發
RTSP,RTMP推流客戶端。

RTSP,RTMP這類協議的開源庫不少,這裏並不打算介紹通訊協議細節。也沒仔細去研究過這些通訊協議。
無非就是在TCP或UDP鏈接中,以一個一個的數據包分界的方式,傳遞視頻幀或者音頻幀。
這在我們自己定義的私有通訊協議中也是這麼做的,比起研究這些通用協議格式,
更願意自己來實現,因爲更加靈活,就比如xdisp_virt.exe遠程控制程序裏,在一條TCP鏈接裏,不單傳輸音視頻幀,
還傳輸鼠標鍵盤數據包,還傳輸各種控制信息數據包,以及其他需要的數據包,想想都挺熱鬧的。

一開始使用的是librtmp開源庫,因爲當時只侷限於實現RTMP推流,沒考慮其他的,
只是等使用librtmp實現了之後,發現使用VLC播放,總是只找到音頻流,沒有視頻,試了多次都是這樣。
當時找不到原因(其實後來找到原因是因爲設置的關鍵幀間隔太大,
足有800這麼大,這在我的私有通訊協議中沒問題,因爲發現缺少SPS,PPS。都會通知被控制端重新刷出關鍵幀。),
也就打算放棄使用librtmp庫,另外使用其他開源庫,後來發現ffmpeg也能實現rtmp推流,
再仔細查看ffmpeg,發現不但能rtmp推流,還能rtsp推流,http推流,還能保存到本地mp4,mkv等多種格式的視頻文件。
既然有這麼多這麼強大的功能,幹嘛死抱着librtmp不放。
而且把桌面屏幕保存到本地視頻文件,一直都是我想做的事,苦於自己對這些MP4封裝格式不大瞭解。

使用的是ffmpeg的3.4的版本,在這個版本中的 doc/example 目錄中的 muxing.c 源代碼,就是實現如何推流的例子。
之所以取名 muxing.c, 在ffmpeg開發團隊看來,rtmp,rtsp這些推流,其實也是把音頻和視頻混合成一定格式”保存“。
跟本地MP4等文件一樣的概念。正是這樣的做法,我們可以在muxing.c中,
使用的ffmpeg統一的API函數,既可以把音視頻保存到本地多種格式的文件,
也可以實時的以rtmp,http,rtsp等多種方式推送到流媒體服務器端,非常的方便。

muxing.c例子代碼中,輸入原始YUV圖像數據或原始聲音數據,然後編碼,然後混合寫入文件或者推流,一氣呵成。
但是我們這裏需要的功能是只需要把已經編碼好的音視頻(H264和AAC)數據幀,直接寫入混合器。
而且要實現可以同時朝多個混合器寫數據。也就是我們可以同時保存到本地MP4文件,
也同時可以朝多個RTSP或RTMP服務器做推流。

如下圖的配置界面,就是最新版本xdisp_virt程序做的推流的網頁方式的配置界面:


圖中設置了三個推流地址,分別朝RTSP, RTMP服務器推送音視頻,同時保存到d盤的t.mp4視頻文件中。
在我們的xidsp_virt遠程桌面控制程序中,編碼好的桌面圖像數據和聲音數據,
一邊要發送給多個WebSocket通訊的網頁客戶端,一邊要發送給多個原生客戶端(當然還包括髮送給中轉服務端),
現在還要發送給RTMP,RTSP,同時保存到本地文件等,
如果朝每個終端發送音視頻流都做一次編碼,是不現實的。這就是爲何要把muxing.c例子代碼裏邊的編碼部分剝離出去,
只保留把編碼好的音視頻幀直接寫入混合器的功能。

我們先來看看調用ffmpeg的API的流程,其實是挺簡單,函數也不多:
首先調用avformat_alloc_output_context2 創建AVFormatContext變量,最後一個參數就是推流地址,或者保存本地的完整路徑,
比如:
AVFormatContext* ctx;
avformat_alloc_output_context2(&ctx, NULL, "flv", "rtmp://192.168.88.1/hls/test"); // 以RTMP協議推送
avformat_alloc_output_context2(&ctx, NULL, "rtsp", "rtsp://192.168.88.1/test"); // 以RTSP協議推送
avformat_alloc_output_context2(&ctx, NULL,NULL, "d:\\Screen.mp4") ;   //保存到本地 Screen.mp4視頻文件中。

之後調用 avformat_new_stream 函數創建視頻流和音頻流,並且添加到ctx變量中。
比如如下代碼就是創建編碼類型爲H264和AAC的視頻和音頻流:

     AVOutputFormat *fmt = ctx->oformat;
    ////固定 H264 + AAC 編碼
    fmt->video_codec = AV_CODEC_ID_H264;
    fmt->audio_codec = AV_CODEC_ID_AAC;
   
     codec = avcodec_find_encoder(AV_CODEC_H264);
     AVStream* st = avformat_new_stream( ctx, codec);
    ...... //其他初始化
   
    codec = avcodec_find_encoder(AV_CODEC_AAC);
     AVStream* st = avformat_new_stream( ctx , codec);
    ...... //其他初始化

   因爲RTSP,RTMP都是網絡通訊,網絡環境複雜多變就必須面臨一個通訊超時問題,寫到本地磁盤倒不必關心這個。
   AVFormatContext結構提供了一個 interrupt_callback 變量,用來設置回調函數,我們可以設置這個回調函數,用來檢測超時。
   如下設置:
   ctx->interrupt_callback.callback = interrupt_cb; // 這個是我們的回調函數,在此函數中用於檢測寫操作是否超時。
   ctx->interrupt_callback.opaque = this;  //
  
   如果不是寫到本地磁盤,而是網絡通訊,我們還必須調用avio_open2來初始化網絡。如下所示:
  
    if (!(fmt->flags & AVFMT_NOFILE)) {
          ////以AVIO_FLAG_WRITE寫方式打開,因爲我們是推流,只寫不讀,stream_url 就是RTSP,RTMP的URL地址。
          ret = avio_open2(&ctx->pb, stream_url.c_str(), AVIO_FLAG_WRITE, &ctx->interrupt_callback, NULL );
    }
  
初始化就這樣完成了。接下來,需要把頭信息寫入,這時候調用 avformat_write_header 函數來寫頭部信息。
寫完頭部信息,我們就可以不斷調用av_interleaved_write_frame 或者 av_write_frame, 把已經編碼好的視頻幀和音頻幀寫入。
ffmpeg底層框架會根據不同協議,做不同的推送處理。
等推流結束了,調用av_write_trailer寫入尾部信息,
如果是寫到本地視頻文件,一定需要調用av_write_trailer,否則視頻文件無法正常使用。

以上流程應該是非常簡潔明瞭的。
在這裏我們只需要ffmpeg的混合器功能,不需要打開編碼器,因此與muxing.c例子不同的是,在avformat_new_stream創建流之後,
不必調用avcodec_open2來打開具體的編碼設備。
既然不打開具體的編碼設備,在調用avformat_write_header 寫頭部信息的時候,ffmpeg框架是不清楚要寫哪些頭部信息的,
因此,我們在調用 avformat_write_header 前,必須還做些額外的工作:
那就是填寫 每個ffmpeg編碼器的 extradata 字段,告訴ffmpeg,頭部應該寫些什麼進去。
比如對於H264編碼的視頻,我們應該填寫 H264 AVCC 格式的extradata頭,用來存儲 SPS,PPS 。
這個幾乎是固定的填寫方法,如下:
假設SPS,PPS的buffer和長度分別是:sps_buffer,pps_buffer 和 sps_len,pps_len。
  
    AVCodecContext* c = video_stream->codec;  // video_stream 是avformat_new_stream創建視頻流返回的AVStream對象。
    int extradata_len = 8 + sps_len + pps_len + 2 + 1;
    c->extradata_size = extradata_len;
    c->extradata = (byte*)av_mallocz(extradata_len);

    int i = 0;
    byte* body = (byte*)c->extradata;

    //H264 AVCC 格式的extradata頭,用來存儲 SPS,PPS
    body[i++] = 0x01;
    body[i++] = sps_buffer[1];
    body[i++] = sps_buffer[2];
    body[i++] = sps_buffer[3];
    body[i++] = 0xff;

    //// SPS
    body[i++] = 0xe1;
    body[i++] = (sps_len >> 8) & 0xff;
    body[i++] = sps_len & 0xff;
    memcpy(&body[i], sps_buffer, sps_len);
    i += sps_len;

    /// PPS
    body[i++] = 0x01;
    body[i++] = (pps_len >> 8) & 0xff;
    body[i++] = (pps_len) & 0xff;
    memcpy(&body[i], pps_buffer, pps_len);
    i += pps_len;

對於AAC音頻編碼的extradata字段,則按照如下方式:
假設聲道數是audio_channel, 採樣率序號是audio_sample_index,
AAC編碼方式(比如是LC,HEv1,HEv2等)是audio_aot, 這些參數都可以從 帶有ADTS頭的AAC編碼數據裏邊解析出來。
至於如何解析,可稍後查看我發佈到CSDN或GITHUB上的源代碼。

    ////// audio, ADTS頭轉換爲MPEG-4 AudioSpecficConfig
    c = audio_stream->codec;    ///audio_stream是創建的音頻流
    c->extradata_size = 2;
    c->extradata = (byte*)av_malloc(2);
    byte dsi[2];
    dsi[0] = ((audio_aot+1) << 3) | (audio_sample_rate_index >> 1);
    dsi[1] = ((audio_sample_rate_index & 1) << 7) | (audio_channel << 3);
    memcpy(c->extradata, dsi, 2);

正確填寫 音頻和視頻對應的extradata字段之後,就可以放心的調用 avformat_write_header函數寫入頭部信息。
這個時候ffmpeg框架判斷如果是rtsp,rtmp之類的則開始建立鏈接,發送各種初始化信息,和頭部信息。
如果是寫本地視頻文件,則寫入視頻頭信息到文件。

然後,我們就開始調用 av_interleaved_write_frame 函數真正的寫已經編碼好的H264視頻幀和AAC音頻幀了。
再開始H264幀前,先了解一些基本概念。

H264視頻碼流目前有兩種打包模式:一種是Annex-B的打包格式,另一種是AVCC打包格式。
Annex-B是傳統的打包格式,絕大部分H264編碼器和解碼器都支持這種格式。在實時傳輸上用得很多,
我們使用x264,openh264默認都是編碼出這種打包格式。
AVCC這種打包格式主要用在文件存儲上,比如存儲成MP4,MKV等文件,
而在RTSP,RTMP這類通訊協議上,也必須採用AVCC格式。
兩者的主要區別:
我們知道,H264是以NALU包來存儲每個slice分片,每個H264幀,可能存儲多個slice,
也就是可能每幀H264編碼的視頻數據,可以包括一個NALU或者多個NALU。
Annex-B打包格式是以00 00 01(三個字節)或者00 00 00 01(四個字節)開始碼來分隔NALU,
而且把SPS,PPS也打包在一起,也是以開始碼分隔。
在AVCC格式中,每個NALU前面是4個字節來表示這個NALU的長度,
而SPS,PPS是分開來存儲,存儲格式就是上面填寫ffmpeg的extradata字段採用的存儲格式。

同理,AAC音頻也是差不多的,我們在實時傳輸時候,一般都會編碼出帶ADTS頭部的AAC數據,
而在存儲文件或者推送 RTMP,RTSP協議的時候,使用的是  MPEG-4 AudioSpecficConfig 格式,因此一樣需要做轉換。

這裏需要提示一下,ffmpeg有個自帶的AAC編碼器,它編碼的AAC數據默認是不帶 ADTS頭部的,
我們一般採用的是fdk-aac庫,這個庫編碼AAC是可以帶ADTS頭部的。

在這裏,輸入的實時H264碼流都是Annex-B標準格式,而且是每個IDR幀(關鍵幀)前都會有SPS,PPS。
輸入的實時AAC碼流,都是帶ADTS頭部的。
我們都會在內部處理的時候對這種格式轉換到RTSP, RTMP推流和本地文件識別的格式,
具體解析處理辦法,可查看稍後發佈到CSDN或GITHUB上的代碼。

似乎一切都妥當了,拿到已經編碼好的數據,直接調用 av_interleaved_write_frame  寫入就可以了,
其實還有一個大麻煩,就是PTS的問題,音頻和視頻同步的問題。
這是很討厭的問題。
PTS我們必須手動計算, 計算這個倒也不難,
我們在開始推流的時候,就是調用avformat_write_header 函數之後,記錄開始時間 start_time=av_gettime(); //單位是微妙
然後每個實時音頻或視頻包到達時間 curr = av_gettime(); 這樣計算PTS:(下面是視頻PTS)
pts = (curr - start_time  )*1.0/1000000.0/av_q2d(video_stream->time_base);//video_stream是創建的視頻流

接下來的就是同步的問題了。
我們推流的是桌面屏幕和是電腦內部聲音,這跟一般的一直在動的視頻不大一樣。
有可能長時間,電腦屏幕都沒任何變化;很長時間,電腦都不發一聲響聲。

一般採用同步方法基本如下三種:
1,以視頻爲準,就是讓視頻以恆定的速度,比如30幀每秒,音頻根據視頻速度來定位。
2,以音頻爲準,讓音頻以恆定速度,比如每秒48000的採樣率,視頻根據音頻來定位。
3,以外部的某個時鐘爲準來同步。
除第三個辦法不知道該怎麼操作外,1, 2都比較好理解。
如果按照1 的辦法,顯然不大現實,因此只好採用第2種辦法,讓音頻以恆定速度傳輸。
既然是讓音頻以恆定速度傳輸,也就是時時刻刻都有數據幀,而電腦內部可能很長時間都沒有發聲。
也就是沒聲音數據。這個時候,我們就得造假了,在這段時間內,我們就得連續不斷的製造靜音幀。

以AAC編碼爲例,AAC編碼的每個sample的大小是固定爲1024,
比如是雙聲道,16位採樣,則每次都需要輸入固定 1024*2*2=4096字節大小的數據,才能編碼出一幀AAC的數據。
也就是我們造假,每次都輸入4096大小的空白數據(就是全是0)讓它編碼出一幀靜音AAC幀。
我們再來計算每一幀AAC數據播放的持續時間。
假設還是雙聲道,16位採樣。
假設每秒48000的採樣率,則每秒可以製造2*2*48000字節 = 192000字節 的PCM原始聲音數據。每幀AAC是4096字節,我們做除法。
4096/192000 = 0.0213秒=21.3毫秒。
在48000採樣率的情況下,每幀AAC數據的播放持續時間大約是21毫秒。
也就是我們每隔21毫秒就要製造一個靜音AAC幀推送出去,這樣在沒有外部聲音的情況下,才能保證聲音的持續不斷。

在windows平臺,可以使用timeSetEvent 函數來達到精確定時。
如下僞代碼:
    ////
    timeSetEvent( 30 , 10, audioTimer, (DWORD_PTR)this,
                    TIME_PERIODIC | TIME_KILL_SYNCHRONOUS | TIME_CALLBACK_FUNCTION);
     ///////
   void  CALLBACK audioTimer(UINT uID, UINT uMsg, DWORD_PTR dwUser, DWORD_PTR dw1, DWORD_PTR dw2){
            int64_t cur = av_gettime(); ///
            ////
            int max_cnt = 4;
            Lock();
            int64_t step = (int64_t)1024 * 1000 * 1000 / audio_sample_rate; // us,每個AAC壓縮幀播放的時間
            int64_t last = max( cur - max_cnt*step, last_audio_timestamp + step );
           ///
           strm_pkt_t pkt; memset(&pkt, 0, sizeof(pkt));
           pkt.type = AUDIO_STREAM;
           pkt.data = audio_mute_data; ///已經編碼好的AAC靜音幀
           pkt.length = audio_mute_size;
           pkt.rawptr = NULL;
           /////
           while (last <= cur -step ) { ////
                 ///
                 pkt.timestamp = last;
                 last_audio_timestamp = last;

                 ////
                 post_strm_pkt(&pkt); ////投遞AAC幀
                 /////
                last += step;
           }
           Unlock();
  }

具體可查詢稍後發佈到CSDN或GITHUB代碼工程。

至此,我們利用ffmpeg實現了RTSP, RTMP推流,以及保存到本地MP4或MKV視頻文件。


源代碼下載:

 GITHUB下載地址:

https://github.com/fanxiushu/stream_push

CSDN下載地址:

https://download.csdn.net/download/fanxiushu/10536116


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