歡迎關注微信公衆號:FSA全棧行動 👋java
項目須要在低端 Android 設備上驅動相機獲取 YUV 圖像,同時,還須要進行錄像,YUV 圖像的獲取與處理以前已經趟過去了,整體感受只要掌握了相機與 YUV 原理等知識點後,結合 libyuv 這個牛逼的庫基本就沒什麼了,而錄像這一塊則是使用 MediaCodec + MediaMuxer
來處理,本篇就是我在使用原生 MediaCodec
編碼 mp4 文件的踩杭記要,主要有兩個問題:android
注:低端的 Android 設備硬件條件有多差呢?大概就是 2014 年 Android4.x 手機那種水平吧,CPU 處理速度很感人,對此,惟有硬編碼纔是王道。安全
在探究該問題前,先來了解一下 MediaCodec
的兩種編碼模式:微信
ByteBuffer 模式
(手動檔):
COLOR_FORMAT
對應的值是 MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
(圖像格式 NV21)。MediaCodec.dequeueInputBuffer()
獲取數據輸入緩衝區,再經過 MediaCodec.queueInputBuffer()
手動將 YUV 圖像傳給 MediaCodec
。Surface 模式
(自動檔):
COLOR_FORMAT
對應的值是 MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
。MediaCodec.createInputSurface()
建立編碼數據源 Surface,再經過 OpenGL 紋理,將相機預覽圖像繪製到該 Surface 上。相機預覽正常,可是錄製出來的 mp4 視頻顏色很陰間。markdown
說明:就跟 YUV 圖像把 u/v 顛倒以後的效果同樣。app
ByteBuffer 模式
下,從相機處獲取到原始的 NV21 圖像,交給設置了 COLOR_FORMAT
爲 COLOR_FormatYUV420SemiPlanar
的 MediaCodec
,結果在不一樣的 Android 設備上,有的正常,有的不正常(少數),剛開始覺得是個別設備上不支持這種 COLOR_FORMAT
,但事實並不是如此。stackoverflow 某個歪果仁對此問題的解釋以下:ide
The YUV formats used by Camera output and MediaCodec input have their U/V planes swapped.
If you are able to move the data through a Surface you can avoid this issue; however, you lose the ability to examine the YUV data. An example of recording to a .mp4 file from a camera can be found on bigflake.
Some details about the colorspaces and how to swap them is in this answer.
...
複製代碼
注:stackoverflow 文章連接:stackoverflow.com/questions/1…工具
因此說,這是 MediaCodec
自己的 bug,它本身會對輸入的 YUV 圖像的 u/v 進行交換,解決的方案有 2 種:oop
ByteBuffer 模式
,在把 NV21 圖像傳給 MediaCodec
以前,先把 NV21 轉成 NV12(畢竟這倆貨僅僅只是 u/v 相反而已),但前面已經提到了,只是少數設備會有這種狀況,適配起來估計有夠嗆的。不推薦
Surface 模式
,能夠完美避免這種狀況,但同時會喪失對原 YUV 圖像的處理能力,不過可使用 OpenGL 方式來處理圖像。推薦
大體步驟以下:ui
mMediaCodec.createInputSurface()
做爲 MediaCodec 的編碼數據源。inputSurface
。說明:Camera ---> TextureId(OpenGL) ---> InputSurface(MediaCodec)
具體實現能夠在 bigflake 的 Demo(CameraToMpegTest) 中獲取:www.bigflake.com/mediacodec/…
解決該問題有兩個關鍵:
ByteBuffer 模式
:經過 MediaCodec.queueInputBuffer()
手動將 YUV 圖像傳給 MediaCodec
的同時,須要傳遞當前的時間戳,注意時間單位是微秒(us)。Surface 模式
:經過 MediaCodec.createInputSurface()
建立出來的 inputSurface,會有與之對比的 mEGLDisplay、mEGLSurface
,在執行 EGL14.eglSwapBuffers(mEGLDisplay, mEGLSurface)
以前,經過 EGLExt.eglPresentationTimeANDROID(mEGLDisplay, mEGLSurface, nsecs)
對 MediaCodec 的 inputSurface 的數據設置時間戳。MediaFormat
的關鍵幀間隔(KEY_I_FRAME_INTERVAL) 與 幀率(KEY_FRAME_RATE) 必須配置得當。說明:這裏是核心總結,可先跳過往下看,以後再回過頭來看,會比較好理解。
錄製一段 10s 的視頻,從設備上提取出來後,使用播放器播放觀察。發現有的設備正常,個別設備錄製出來的視頻,時長僅僅只有一半,這也就是網上都在說的 play too fast
問題。
安利:
OnlyStopWatch_x64.exe
這是一個計時器小工具,對於視頻錄製、直播這種須要觀察時間快慢的場景很實用。畫面丟失、播放太快等問題,都很容易看出來。
前面提到的 stackoverflow 問答中,那個歪果仁同時也表達了他對 使用MediaCodec錄製出來的視頻播放太快
這個問題的解釋:
...
There is no timestamp information in the raw H.264 elementary stream. You need to pass the timestamp through the decoder to MediaMuxer or whatever you're using to create your final output. If you don't, the player will just pick a rate, or possibly play the frames as fast as it can.
複製代碼
注:stackoverflow 文章連接:stackoverflow.com/questions/1…
他認爲 H.264 不包含時間戳信息,你須要把時間戳經過編碼器(MediaCodec)給到媒體複用器(MediaMuxer),不然,播放器會選擇一個速率,儘快地播放幀。
若是是 ByteBuffer 模式
,則核心代碼實現以下:
private void feedMediaCodecData(byte[] data) {
if (!isEncoderStart)
return;
int bufferIndex = -1;
try {
bufferIndex = mMediaCodec.dequeueInputBuffer(0);
} catch (IllegalStateException e) {
e.printStackTrace();
}
if (bufferIndex >= 0) {
ByteBuffer buffer = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
try {
buffer = mMediaCodec.getInputBuffer(bufferIndex);
} catch (Exception e) {
e.printStackTrace();
}
} else {
if (inputBuffers != null) {
buffer = inputBuffers[bufferIndex];
}
}
if (buffer != null) {
buffer.clear();
buffer.put(data);
buffer.clear();
// 納秒(ns) 轉 微秒(us)
mMediaCodec.queueInputBuffer(bufferIndex, 0, data.length, System.nanoTime() / 1000, MediaCodec.BUFFER_FLAG_KEY_FRAME);
}
}
}
複製代碼
這時要注意,MediaCodec
須要的時間單位是微秒(us),若是你不使用正確的時間,可能會出問題,好比:stackoverflow.com/questions/2…
補充:秒(s)、毫秒(ms)、微秒(us)、納秒(ns),之間的比例都是 1:1000。
若是是 Surface 模式
,則核心代碼實現以下:
// 更新紋理圖像
// Acquire a new frame of input, and render it to the Surface. If we had a GLSurfaceView we could switch EGL contexts and call drawImage() a second time to render it on screen. The texture can be shared between contexts by passing the GLSurfaceView's EGLContext as eglCreateContext()'s share_context argument.
mSurfaceTexture.updateTexImage();
mSurfaceTexture.getTransformMatrix(mSTMatrix);
// 傳入時間戳信息
// Set the presentation time stamp from the SurfaceTexture's time stamp. This will be used by MediaMuxer to set the PTS in the video.
mInputSurface.setPresentationTime(mSurfaceTexture.getTimestamp());
// Submit it to the encoder. The eglSwapBuffers call will block if the input is full, which would be bad if it stayed full until we dequeued an output buffer (which we can't do, since we're stuck here). So long as we fully drain the encoder before supplying additional input, the system guarantees that we can supply another frame without blocking.
mInputSurface.swapBuffers();
複製代碼
具體實現能夠在 bigflake 的 Demo(CameraToMpegTest) 中獲取:www.bigflake.com/mediacodec/…
儘管按照上面的步驟,把時間戳正確傳遞給 MediaMuxer
了,但依舊無濟於事。通過將我項目中的代碼與 bigflake 的 CameraToMpegTest 中的代碼進行對比,發現,MediaFormat
的配置上也很關鍵,必須配置得當,不然也仍是會出現時長縮水的問題,因而我將原來項目中的代碼進行修改,將幀率修改成 30f,關鍵幀間距改成 5s,丟幀的問題這才解決了,MediaFormat
配置具體代碼以下:
protected static final String MIME_TYPE = "video/avc";
protected static final int FRAME_INTERVAL = 5; // 間隔5s插入一幀關鍵幀
protected static final int FRAME_RATE = 30;
protected static final float BPP = 0.50f;
protected int mColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface;
private void initMediaCodec(){
final MediaFormat format = MediaFormat.createVideoFormat(MIME_TYPE, mWidth, mHeight);
format.setInteger(MediaFormat.KEY_COLOR_FORMAT, mColorFormat);
format.setInteger(MediaFormat.KEY_BIT_RATE, calcBitRate());
format.setInteger(MediaFormat.KEY_FRAME_RATE, FRAME_RATE);
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, FRAME_INTERVAL);
try {
mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE);
} catch (IOException e) {
e.printStackTrace();
}
mMediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
// get Surface for encoder input
// this method only can call between #configure and #start
// API >= 18
mSurface = mMediaCodec.createInputSurface();
mMediaCodec.start();
}
protected int calcBitRate() {
final int bitrate = (int) (BPP * FRAME_RATE * mWidth * mHeight);
Log.i(TAG, String.format("bitrate=%5.2f[Mbps]", bitrate / 1024f / 1024f));
return bitrate;
}
複製代碼
另外,親測只要 MediaFormat
配置沒問題,就算時間戳不傳遞也沒影響,emmm...,既然時間戳的代碼已經寫好了,又暫時沒出現其餘坑,爲了安全起見,仍是把時間戳信息帶上吧。
若是文章對您有所幫助, 請不吝點擊關注一下個人微信公衆號:FSA全棧行動, 這將是對我最大的激勵. 公衆號不只有Android技術, 還有iOS, Python等文章, 可能有你想要了解的技能知識點哦~