音视频基础(二)下:编码之h264解析②

音视频基础(二)下:编码之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

参考

  1. 视频编码(1):可能是最详尽的 H.264 编码相关概念介绍丨音视频基础
  2. Network Abstraction Layer Unit Types
  3. 音视频数据处理入门:解析h264视频码流
相关推荐
anyup_前端梦工厂10 分钟前
探索 Web Speech API:实现浏览器语音识别与合成
前端·javascript·html
Jacky-YY18 分钟前
Nginx-HTTP和反向代理web服务器
服务器·前端·nginx·http
软件技术NINI23 分钟前
Vue 3 是 Vue.js 的下一代版本,它在许多方面都带来了显著的改进和变化,旨在提高开发效率和用户体验
前端·vue.js·ux
caperxi27 分钟前
当 PC 端和移动端共用一个域名时,避免 CDN 缓存页面混乱(nginx)
前端·nginx·缓存
Book_熬夜!35 分钟前
HTML和CSS做一个无脚本的手风琴页面(保姆级)
前端·css·平面·html·html5
毓离37 分钟前
Vue路由
前端·javascript·vue.js
北原_春希39 分钟前
vuex和redux的区别
开发语言·前端·vue.js·react.js
和风微凉1 小时前
Highcharts甘特图基本用法(highcharts-gantt.js)
前端·javascript·echarts·甘特图
LvManBa1 小时前
HTML5中新增元素介绍
前端·html·html5
程序员阿龙1 小时前
计算机毕业设计之:基于uni-app的校园活动信息共享系统设计与实现(三端开发,安卓前端+网站前端+网站后端)
前端·uni-app·移动端开发·校园应用开发·实时信息共享·信息系统设计与实现·校园活动管理