经过 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
别的机会