部分 Android 手機硬壓視訊和 IOS 播放器不相容的問題
通過 MediaExtractor 將 mp4 檔案分解成 h264 碼流檔案和 aac 音訊檔案,再使用 MediaCodec 解碼 h264 得到畫素資料。降低畫面解析度、設定位元速率和關鍵幀間隔後通過 MediaCodec 重新編碼得到 h264 檔案,然後通過 mp4parser 將壓縮後的 h264 碼流和前面的 aac 音訊檔案重新合成 mp4 檔案。因為音訊資料佔極少一部分,所以只對碼流檔案進行壓縮。
遇到的問題
一加 5T、小米手機壓縮得到的視訊在 IOS 系統上播放時剛開始幾秒出現綠屏。

問題分析
通過調整關鍵幀間隔發現出問題的幀都位於第一個關鍵幀之後第二個關鍵幀之前,加上出問題的播放器都是蘋果系的,猜測共用一塊核心程式碼。所以懷疑是因為播放器沒有正確讀取到第一個關鍵幀資訊,導致依賴它的幀都不能正常顯示。
首先懷疑可能是第一個關鍵幀不存在,通過下面這條命令檢查。
ffprobe -i issue_video.mp4 -select_streams v -show_frames -show_entries frame=pkt_dts_time,pict_type -v error -of csv | grep -n I 複製程式碼
輸出結果如下:
1:frame,0.000000,I 301:frame,10.003756,I 601:frame,20.007633,I 901:frame,30.011556,I 複製程式碼
可以看到關鍵幀分別位於第 1、301、601、901 幀,對應的時間點分別是第 0、10、20、30 秒,說明第一個關鍵幀是存在的,看來這個懷疑是錯的。
接下來從 mp4 檔案中分離出 h264 碼流資料分析它的 nal 是否正常,分離 h264 命令如下:
ffmpeg -i issue_video.mp4 -vcodec copy -vbsf h264_mp4toannexb -an issue_video.h264 複製程式碼
分析 nal 我這裡用的是 ofollow,noindex">sourceforge.net/projects/h2… 這裡的 h264 分析器,輸出的結果如下:

從上面的截圖中可以看到三個 nal,type 分別為 6、5、1,指的是 SEI、IDR、non-IDR,這裡的 IDR 就是第一個關鍵幀。
一個正常視訊的 nal log 如下,作為對比


可以看到前三個 nal type 分別是 7、8、5,分別指的是 SPS PPS IDR。sps/pps 一般包含了初始化 H264 解碼器所需要的資訊引數,包含編碼所用的 profile,level,影象的寬高等資訊,所以在將影象資料送入解碼器之前必須先將 sps/pps 送入解碼器。問題視訊除第一個關鍵幀外的剩餘三個關鍵幀之前都有 sps/pps,而這三個關鍵幀後的視訊都能正常播放,更加證明了問題出在第一個關鍵幀之前沒有 sps/pps。
分析到這一步只能說明問題視訊 mp4 檔案轉成得到的 h264 檔案有問題,但 mp4 檔案中 sps/pps 是作為 meta 資料全域性存在 avcC box 中的,如下圖:

所以應該是在 mp4 檔案轉成 h264 過程中出問題了。通過再次對比問題視訊和正常視訊的 nal log 發現除了沒有 sps/pps 之外還有一處不相同,問題視訊在最開始的地方多了一個 SEI nal。SEI 全稱 Supplemental enhancement information 即補充增強資訊,可以理解為補充資訊,一般用於存放使用者自定義資料,如果和視訊解碼沒關係時可直接忽略。它在 H264 碼流中的位置需要滿足下面條件:
如果存在SEI(補充增強資訊) 單元的話,它必須在它所對應的基本編碼影象的片段(slice)單元和資料劃分片段(data partition)單元之前,並同時必須緊接在上一個基本編碼影象的所有片段(slice)單元和資料劃分片段(data partition)單元后邊。假如SEI屬於多個基本編碼影象,其順序僅以第一個基本編碼影象為參照。Reference
懷疑問題視訊的 SEI nal 不應該放在最開始,嘗試在壓縮後的 h264 碼流中將 SEI nal 拿掉,視訊可以正常播放了。所以問題出在蘋果系播放器不能正常解析或者過濾掉位於檔案開始的 SEI nal。
解決方案
MediaCodec 編碼後的資料中將 SEI nal 過濾掉。有的人可能會問直接拿掉這段資料會不會引起播放錯誤,nal 資料的第一個位元組中: bit0 通常為 0,bit1-2 表示是否被別的 nal 資料引用,0 表示沒被引用,非 0 表示被引用,值越大表示越重要,bit3-7 表示 nal type。而 SEI nal 的第一個位元組的 bit1-2 一般都是0 表示沒有被引用,所以直接過濾掉不會引起錯誤。過濾程式碼如下:
private fun ByteBuffer.filterSEINalu(info: MediaCodec.BufferInfo): ByteBuffer { var seiFound = false var start = -1 var totalByteArray = ByteArray(0) for (i in position()..limit()) { getStartCodeLength(i).takeIf { it > 0 && limit() > it + 1 }?.also { if (start >= 0) { totalByteArray += getArray(start, i) } val firstByte = get(i + it) if ((firstByte and 0x60) == 0.toByte() && (firstByte and 0x1F) == 6.toByte()) { if (seiFound.not()) { seiFound = true totalByteArray += getArray(position(), i) } start = -1 RgLog.i("Found sei nalu index $i") } else if (seiFound) { start = i } } if (i == limit() && start >= 0) { totalByteArray += getArray(start, i) } } return if (seiFound) { info.size -= remaining() - totalByteArray.size ByteBuffer.wrap(totalByteArray) } else this } private fun ByteBuffer.getStartCodeLength(index: Int): Int { if (index < position() || index >= limit()) { return 0 } if (this.limit() > index + 2 && (index == position() || this[index - 1] != 0.toByte()) && this[index] == 0.toByte() && this[index + 1] == 0.toByte() && this[index + 2] == 1.toByte()) { // 000001 return 3 } else if (this.limit() > index + 3 && this[index] == 0.toByte() && this[index + 1] == 0.toByte() && this[index + 2] == 0.toByte() && this[index + 3] == 1.toByte()) { // 00000001 return 4 } return 0 } private fun ByteBuffer.getArray(start: Int, end: Int): ByteArray { val byteArray = ByteArray(end - start) val oldPos = position() position(start) get(byteArray, 0, byteArray.size) position(oldPos) return byteArray } 複製程式碼
這段程式碼是用 kotlin 寫的,主要是 ByteBuffer.filterSEINalu 這個方法,因為 nal 沒有欄位來表示資料的 length,而是通過 000001 或者 00000001 作為開始碼來標記每個 nal,這裡的 ByteBuffer.getStartCodeLength 就是用來判斷是不是開始碼。
if ((firstByte and 0x60) == 0.toByte() && (firstByte and 0x1F) == 6.toByte()) 複製程式碼
核心程式碼是這行,用來判斷這個 nal 是不是 SEI,並且 bit1-2 必須為0 確保沒有被引用。
如果你也喜歡 Android App 開發,可以發簡歷給我[email protected]
別的機會