項目需求是實時同步Android手機屏幕畫面至瀏覽器。這裏有兩個挑戰,一是Android如何在應用內獲得屏幕實時視頻流,另一個是如何在瀏覽器上做視頻直播。經過一番折騰,確定瞭如下的實現方案。期間,我們也實現了手機攝像頭的直播。
演示效果:
Android獲取實時屏幕畫面
原理與基礎設置
Android 5.0版本之後,支持使用MediaProjection
的方式獲取屏幕視頻流。具體的使用方法和原理如下圖所示:
參考ScreenRecorder項目3的實現,我們瞭解到VirtualDisplay
可以獲取當前屏幕的視頻流,創建VirtualDisplay
只需通過MediaProjectionManager
獲取MediaProjection
,然後通過MediaProjection
創建VirtualDisplay
即可。
那麼視頻數據的流向是怎樣的呢?
- 首先,Display 會將畫面投影到 VirtualDisplay中;
- 接着,VirtualDisplay 會將圖像渲染到 Surface中,而這個Surface是由MediaCodec所創建的;
- 最後,用戶可以通過MediaCodec獲取特定編碼的視頻流數據。
經過我們的嘗試發現,在這個場景下,MediaCodec只允許使用video/avc編碼類型,也就是RAW H.264的視頻編碼,使用其他的編碼會出現應用Crash的現象(不知是否與硬件有關?)。由於這個視頻編碼,後面我們與它“搏鬥”了好一段時間。
以下是關鍵部分的代碼(來自ScreenRecorder項目3):
codec = MediaCodec.createEncoderByType(MIME_TYPE);
mSurface = codec.createInputSurface();
mVirtualDisplay = mMediaProjection.createVirtualDisplay(
name,
mWidth,
mHeight,
mDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,
mSurface, // 圖像會渲染到Surface中
null,
null);
在編碼之前,我們還需要設置視頻編碼的一些格式信息,這裏我們通過MediaFormat
進行編碼格式設置,代碼如下(來自ScreenRecorder項目3)。
private static final String MIME_TYPE = "video/avc"; // H.264編碼
private static final int FRAME_RATE = 30; // 30 FPS
private static final int IFRAME_INTERVAL = 10; // I-frames間隔時間
private static final int TIMEOUT_US = 10000;
private void prepareEncoder() throws IOException {
MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, mWidth, mHeight);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
format.setInteger(MediaFormat.KEY_BIT_RATE, mBitRate);
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL);
codec = MediaCodec.createEncoderByType(MIME_TYPE);
codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
codec.start();
}
數據獲取
圖片來自Android官方文檔
緊接着,我們需要實時獲取視頻流了,我們可以直接從MediaCodec
中獲取視頻數據。
根據官方文檔,獲取視頻流有兩種做法。一種是通過異步的方式獲取數據,使用回調來獲取OutputBuffer
,具體代碼詳見Android文檔。
這裏我們瞭解一下同步獲取的方式,由於是同步執行,爲了不阻塞主線程,必然需要啓動一個新線程來處理。首先,程序會進入一個循環(可以設置變量進行停止),我們通過codec.dequeueOutputBuffer()
方法獲取到outputBufferId
,接着通過ID獲取buffer
。這個buffer
即是我們需要用到的實時視頻幀數據了。代碼如下(來自Android官方文檔):
MediaFormat outputFormat = codec.getOutputFormat(); // 方式二
codec.start();
for (;;) {
int outputBufferId = codec.dequeueOutputBuffer(mBufferInfo, 10000);
if (outputBufferId >= 0) {
ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
// 方式一
MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId);
codec.releaseOutputBuffer(outputBufferId, …);
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
outputFormat = codec.getOutputFormat(); // 方式二
}
}
codec.stop();
codec.release();
按照ScreenRecorder項目3的做法,接着他使用MediaMuxer
的Muxer.writeSampleData()
方法,直接將視頻流outputBuffer
寫入了文件。
然而,我們需要的是實時推流至服務器。那麼,接下去應該如何實現呢?
視頻推流
這裏有一個小插曲,爲了完成這個項目,我和同學查閱了不少資料和源碼。其中有一個RtmpRecoder項目2使用FFmpeg
進行實時攝像頭的RTMP
推流,推流的原理如下圖所示。
FFmpeg是一個大名鼎鼎的音視頻轉碼庫,它由C語言實現,因此在Java中,我們需要通過JNI進行調用,這裏,我們使用了JavaCV1的FFmpeg轉碼功能。
注意:如果使用JavaCV並採用
mpeg1video
格式推流至服務器,切記將聲道調爲0,recorder.setAudioChannels(0)
,否則視頻會殘缺不全。說到這裏,不得不吐槽一下JavaCV1,它沒有文檔,沒有文檔是件很可怕的事情,編程基本靠猜。而且它也沒有實現FFmpeg的全部功能!!!我們爲了把獲取到的視頻幀流數據傳給JavaCV費了好大功夫,曾經一度想通過調用C語言函數來完成這項工作,但沒有成功!
到最後黔驢技窮,只好去項目中開Issue尋求幫助,然而作者表示尚未實現該功能,WTF。好吧,畢竟開源項目,別人也沒有義務去做這件事。所以最後也只能自己來解決這個問題了。
廢話不多說,既然JavaCV1無法完成這項工作,那麼我們只好另闢蹊徑。
現在,有兩種做法。
- 自己編寫FFmpeg類庫。我嘗試直接使用CLI接入stream的方式實現實時推流。方法也很簡單,只需要在Java中啓動
FFmpeg
進程,然後pipe輸入流,再由FFmpeg
推流至服務器。但實踐之後發現一些奇怪的問題,只好作罷。 - 另一個方案就是徒手來處理視頻幀數據,將轉碼的工作放到服務器端去實現,最後我們使用這個方案成功完成了任務。下面來看看H.264編碼:
H.264編碼
衆所周知,視頻編碼格式種類繁多,H.264也是其中一種編碼,每一種編碼都有其特點和適用場景,更多信息請自行搜索,這裏不多做贅述。期間,我們嘗試過將上面獲取到的視頻幀數據保存爲文件,想研究視頻文件爲什麼會呈現爲綠屏的畫面。經過翻閱資料和試驗我們發現,H.264編碼有着特殊的分層結構。
H.264 的功能分爲兩層:視頻編碼層(VCL, Video Coding Layer)和網絡提取層(NAL, Network Abstraction Layer)。VCL 數據即編碼處理的輸出,它表示被壓縮編碼後的視頻數據 序列。在 VCL 數據傳輸或存儲之前,這些編碼的 VCL 數據,先被映射或封裝進 NAL 單元中。每個 NAL 單元包括一個原始字節序列負荷(RBSP, Raw Byte Sequence Payload)、一組對應於視頻編碼的 NAL 頭信息。RBSP 的基本結構是:在原始編碼數據的後面填加了結尾比特。一個bit“1”若干比特“0”,以便字節對齊。
因此,爲了將幀序列變成合法的H.264編碼,我們需要手動構建NAL單元。H.264的幀是以NAL單元爲單位進行封裝的,NAL單元的結構如上圖所示。H.264分爲Annexb
和RTP
兩種格式,RTP
格式更適合用於網絡傳輸,因爲其結構更加節省空間,但由於Android系統提供的數據本身就是Annexb
格式的,因此我們採用Annexb
格式進行傳輸。
按照Annexb
格式的要求,我們需要將數據封裝爲如下格式:
0000 0001 + SPS + 0000 0001 + PPS + 0000 0001 + 視頻幀(IDR幀)
H.264的SPS和PPS串,包含了初始化H.264解碼器所需要的信息參數,包括編碼所用的profile,level,圖像的寬和高,deblock濾波器等。
然後不斷重複以上格式即可輸出正確的H.264編碼的視頻流了。這裏的SPS和PPS在每一個NAL單元中重複存在,主要是適用於流式傳播的場景,設想一下如果流式傳播過程中漏掉了開頭的SPS和PPS,那麼整個視頻流將永遠無法被正確解碼。
我們在實踐過程中,SPS和PPS只傳遞了一次,這樣的方式比較適合我們的項目場景,也比較省流量。因此在我們的方案中,格式變爲如下形式:
0000 0001 + SPS + 0000 0001 + PPS + 0000 0001 + 視頻幀(IDR幀)+ 0000 0001 + 視頻幀 + ...
H.264編碼比較複雜,我也只是在做項目期間查閱一些資料纔有一點大概的瞭解,然後在項目完成之後纔去反思和理解背後的原理。如果要深入學習,可以查閱相關的資料(H.264視頻壓縮標準5)。
介紹完H.264的基本原理,下面看看Android上具體的實現。其實Android系統的MediaCodec
類庫已經幫助我們完成了較多的工作,我們只需要在開始錄製時(或每一次傳輸視頻幀前)在視頻幀之前寫入SPS和PPS信息即可。MediaCodec
已經默認在數據流(視頻幀和SPS、PPS)之前添加了start
code
(0x01),我們不需要手動填寫。
SPS和PPS分別對應了bufferFormat
中的csd-0
和csd-1
字段。
...
} else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
MediaFormat outputFormat = codec.getOutputFormat();
outputFormat.getByteBuffer("csd-0"); // SPS
outputFormat.getByteBuffer("csd-1"); // PPS
/* 然後直接寫入傳輸流 */
}
服務器端
實時的數據流通過Socket(tcp)傳輸到服務器端,服務器端採用Node.js
實現視頻流轉碼和WebSocket
轉播。爲了使Web前端可以播放實時的視頻,我們必須將格式轉換爲前端支持的視頻格式,這裏解碼使用FFmpeg
的Node.js封裝(stream-transcoder項目6)。以下是Socket通訊和轉碼的關鍵代碼:
var Transcoder = require('stream-transcoder');
var net = require('net');
net.createServer(function(sock) {
sock.on('close', function(data) {
console.log('CLOSED: ' +
sock.remoteAddress + ' ' + sock.remotePort);
});
sock.on('error', (err) => {
console.log(err)
});
// 轉碼 H.264 => mpeg1video
new Transcoder(sock)
.size(width, height)
.fps(30)
.videoBitrate(500 * 1000)
.format('mpeg1video')
.channels(0)
.stream()
.on('data', function(data) {
// WebSocket轉播
socketServer.broadcast(data, {binary:true});
})
}).listen(9091);
Web直播
緊接着,Web前端與服務器建立WebSocket
連接,使用jsmpeg項目7對mpeg1video的視頻流進行解碼並呈現在Canvas上。
var client = new WebSocket('ws://127.0.0.1:9092/');
var canvas = document.getElementById('videoCanvas');
var player = new jsmpeg(client, {canvas:canvas});
後續還可以做一些靈活的配置以及錯誤處理,可以讓整個直播的流程更加穩定。至於視頻方面的優化,也可以繼續嘗試各種參數的調節等等。
爲了完成這個項目,我和我的另一個同學前後花費了四五天的時間,進行各種摸索和嘗試,所以我決定記錄下這個方案,希望可以幫助到有需要的人。
其他
- 參考JavaCV項目
- 參考RtmpRecoder開源項目的實現
- 參考ScreenRecorder開源項目的實現
- 參考Android文檔
- 文獻H.264視頻壓縮標準
- 使用stream-transcoder項目
- 使用jsmpeg項目