音视频基础(二)下:编码之h264解析②
上篇我们了解了h264的帧内压缩和帧间压缩技术,简单的概括就是 划分->预测->补偿->变化->量化->熵编码 这六个步骤,同时我们还了解了h264的性能评价方式:率失真函数值。
其实在工作中,我们通常不会用到这么底层的东西,但是多了解一些总有好处,至少能对之后h265为什么比h264强有个概念。
而这篇要将的就是工作中非常实际的问题了,也就是前端实现播放器的原理。
1 分层结构
H264实际上由两层组成,一层为 视频编码层VCL ,一层为 网络适配层NAL。
未编码的数据先交给VCL进行编码,这实际上就是上篇的编码过程。编码完成后得到的数据被称为 SODB(string of data bits)
下图就是SODB的结构
而为了更好的在网络中传播(一个直接的目的:网络包裹小于1500字节,但是一帧数据通常大于1500字节,于是需要分包),SODB经由NAL打包,生成RBSP(raw byte sequence payload) 。这里实际上就是在SODB尾部加上结尾比特(Trail)用于字节对齐。
然后再在 RBSP 头部加上 NAL Header 来组成一个一个的 NAL 单元,也就是我们常说的NALU:
2 码流格式
一般来说,H264码流的格式就是一个个NALU,因为都有NAL头部,所以很好区分起始点。但是在DVD光盘等介质上存储时,由于是紧密排列的,所以可能出现混淆,于是又再在NAL头部前加上了起始码0x000001,这就是所谓的 Annex-b码流格式
当然,Annex-b格式并不只是添加了起始码,还有另一个机制:防止竞争。简单的来说:NAL中可能出现与起始码相同的序列,这就要在编码时加上一个0x03来进行区分。也就是说,解码时需要考虑去除这部分,还原原始数据:
rust
0x00000300 -> 0x000000
0x00000301 -> 0x000001
0x00000302 -> 0x000002
0x00000303 -> 0x000003
0x000002是保留未作使用的序列,0x000003也做转换则是防止原始数据就是0x000003的情况下原始数据被破坏
3 NAL头部
NAL头部一共8个字节:
diff
+---------------+
|0|1|2|3|4|5|6|7|
+-+-+-+-+-+-+-+-+
|F|NRI| Type |
+---------------+
-
F代表
forbidden_zero_bit
,永远是0,如果为1就是出现了语法错误 -
NRI代表
nal_ref_idc
,第 1-2 位,表示当前 NAL 的优先级。取值为[00,01,10,11],也就是0到3。-
当值为00时,表示NAL的内容单元不参与重建其他帧,也就是不是参考帧
-
当值为任意非0值时,可以是参考帧,或者SPS和PPS
SPS和PPS上篇好像漏掉了,这里补充一下:
SPS即序列参数集,存储一组编码后的图像序列的依赖的全局参数,包括编码协议、分辨率、帧数等。
PPS即图像参数集,存储一帧的图像序列的依赖的参数,包括熵编码选择、一帧中有多少个片组等。
-
-
Type,第3-7位,表示当前NAL单元的类型,共有5位也就是最多有32种类型,但是目前只有11种类型被使用,其他保留未使用
- 被使用的是1-12,具体类型如下:
4 解码器实现
首先强调一点,目前主流浏览器都支持h264 MP4 原生播放,不需要像h265那样需要转码处理。所以这里进行解码只是为了了解,没有什么实际意义。
我们首先可以通过fflmpeg拿到h264的原始码流,也就是rawSteam,方法如下:
css
ffmpeg.exe -i 路径\文件名.mp4 -vcodec copy -bsf h264_mp4toannexb -f h264 文件名.h264
这里选前面几行如下:
r
00000000 00 00 00 01 67 42 80 2A DA 01 10 0F 1E 5E 52 0A ....gB.*.....^R.
00000010 0C 0A 0D A1 42 6A 00 00 00 01 68 CE 06 E2 00 00 ....Bj....h.....
00000020 00 01 65 B8 40 F7 0F 84 3F 0F 42 E0 00 42 93 45 ..e.@...?.B..B.E
00000030 1E BF FF E0 C5 4B 1E A0 3D AE 5B FF 8D 3D 34 DA .....K..=.[..=4.
00000040 C2 1A FF E0 89 5E CF DA AB 58 F5 00 08 3E BB EE .....^...X...>..
00000050 FF FC 13 68 3B F6 B6 BF FF 7D C5 05 78 4D D4 69 ...h;....}..xM.i
很明显00 00 00 01是起始码,这里没有呈现的是,起始码可以为00 00 00 01或者00 00 01,在SPS,PPS,SEI,I帧的第一片中是4个字符长,其他是3个字符长。
了解了其结构之后,我们就可以开始划分出一个个NALU了:
1.我们首先需要获取到码流内容,并且将其转换为可操作的格式DataView:
javascript
function getRawSteam() {
let result;
return new Promise(function (resolve, reject) {
document.getElementById('fileInput').addEventListener(
'change',
function (e) {
const file = this.files[0];
const fileReader = new FileReader();
fileReader.onload = function () {
result = fileReader.result;
result = new DataView(result);
resolve(result);
};
fileReader.readAsArrayBuffer(file);
},
false
);
});
}
2.我们对两种起始码的情况进行分类,并进行划分
ini
// 起始码的长度可以是3或4
// SPS,PPS,SEI,I帧的第一片是4,其他的是3
function getStartcodeLen(dataView, start) {
if (start >= dataView.byteLength) {
return -1;
}
let startcode = 0;
if (dataView.getInt8(start) == 0 && dataView.getInt8(start + 1) == 0) {
if (dataView.getInt8(start + 2) == 1) {
startcode = 3;
} else if (
dataView.getInt8(start + 2) == 0 &&
dataView.getInt8(start + 3) == 1
) {
startcode = 4;
}
}
return startcode;
}
function getNaluList(dataView, start) {
let naluList = [];
while (start + 4 < dataView.byteLength) {
let nalu = [];
let starcodeLen = getStartcodeLen(dataView, start);
if (starcodeLen === -1) {
break;
}
while (starcodeLen === 0) {
nalu.push(dataView.getInt8(start));
start++;
starcodeLen = getStartcodeLen(dataView, start);
}
nalu.length !== 0 && naluList.push(nalu);
start += starcodeLen;
nalu = [];
}
return naluList;
}
3.获取NALU其中的关键数据,如type等
ini
const Nalu_type = {
SLICE: 1,
DPA: 2,
DPB: 3,
DPC: 4,
IDR: 5,
SEI: 6,
SPS: 7,
PPS: 8,
AUD: 9,
EOSEQ: 10,
EOSTREAM: 11,
FILL: 12,
};
const Nalu_priority = {
DISPOSABLE: 0,
LOW: 1,
HIGH: 2,
HIGHEST: 3,
};
// 获取type
function getNaluType(nalu) {
//例子: 103 & 0x1f = 7
let typeNumber = nalu[0] & 0x1f;
return Object.keys(Nalu_type).find((key) => Nalu_type[key] == typeNumber);
}
// 获取priority
function getNaluePriority(nalu) {
// 例子: (103 & 0x60) >> 5 = 3
let priorityNumber = (nalu[0] & 0x60) >> 5;
return Object.keys(Nalu_priority).find(
(key) => Nalu_priority[key] == priorityNumber
);
}
function getTableData(naluList) {
const tableData = [];
for (let i = 0; i < naluList.length; i++) {
let naluData = {};
naluData['id'] = i;
naluData['type'] = getNaluType(naluList[i]);
naluData['priority'] = getNaluePriority(naluList[i]);
if (naluData['type'] === undefined || naluData['priority'] == undefined) {
console.log('出错了!');
}
naluData['length'] = naluList[i].length;
tableData.push(naluData);
}
console.log('解码结果: ', tableData);
return tableData;
}
解码结果如下:
在这其中,最为重要的内容是SPS中的内容,它保存了如帧率、分辨率等内容,因为其中使用了哥伦布编码,所以还需要进一步对其进行解码,可以参考flv.js
的解码方式:demux