部分 Android 手機硬壓視頻和 IOS 播放器不兼容的問題

Android 硬壓視頻

經過 MediaExtractor 將 mp4 文件分解成 h264 碼流文件和 aac 音頻文件,再使用 MediaCodec 解碼 h264 獲得像素數據。下降畫面分辨率、設置碼率和關鍵幀間隔後經過 MediaCodec 從新編碼獲得 h264 文件,而後經過 mp4parser 將壓縮後的 h264 碼流和前面的 aac 音頻文件從新合成 mp4 文件。由於音頻數據佔極少一部分,因此只對碼流文件進行壓縮。html

遇到的問題

一加 5T、小米手機壓縮獲得的視頻在 IOS 系統上播放時剛開始幾秒出現綠屏。
web

問題分析

經過調整關鍵幀間隔發現出問題的幀都位於第一個關鍵幀以後第二個關鍵幀以前,加上出問題的播放器都是蘋果系的,猜想共用一塊核心代碼。因此懷疑是由於播放器沒有正確讀取到第一個關鍵幀信息,致使依賴它的幀都不能正常顯示。bash

首先懷疑多是第一個關鍵幀不存在,經過下面這條命令檢查。ide

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
複製代碼

輸出結果以下:ui

1:frame,0.000000,I
301:frame,10.003756,I
601:frame,20.007633,I
901:frame,30.011556,I
複製代碼

能夠看到關鍵幀分別位於第 一、30一、60一、901 幀,對應的時間點分別是第 0、十、20、30 秒,說明第一個關鍵幀是存在的,看來這個懷疑是錯的。this

接下來從 mp4 文件中分離出 h264 碼流數據分析它的 nal 是否正常,分離 h264 命令以下:編碼

ffmpeg -i issue_video.mp4 -vcodec copy -vbsf h264_mp4toannexb -an issue_video.h264
複製代碼

分析 nal 我這裏用的是 sourceforge.net/projects/h2… 這裏的 h264 分析器,輸出的結果以下:spa

從上面的截圖中能夠看到三個 nal,type 分別爲 六、五、1,指的是 SEI、IDR、non-IDR,這裏的 IDR 就是第一個關鍵幀。.net

一個正常視頻的 nal log 以下,做爲對比code

能夠看到前三個 nal type 分別是 七、八、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 開發,能夠發簡歷給我 zhanglei@okjike.com
別的機會

相關文章
相關標籤/搜索