nodejs环境下对h264编码mp4的关键帧导出探讨(二)

1,384 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

前面文章介绍了mp4和h264编码的一些知识点,为我们顺利找到关键帧的数据提供了途径。这一部分,我们将着重解决从关键帧数据中还原图像。 现在在我们面前的是第一个sample的位置和长度信息,我们顺利的找到了一段十六进制数据,在正式开始之前,还有几个问题需要理清楚,这也是本人在实际操作过程中掉进去的坑。

  1. h264编码的封装格式问题
  2. 解码器的接入
  3. 图像导出

h264码流格式

H.264码流分Annex-B和AVCC两种格式。很说地方有不同叫法,这一点在前一部分文章已经了解过。这里再详细阐述一下,这一部分不弄清楚,解码是解不出来的。

  • AVCC格式 也叫AVC1格式,MPEG-4格式,字节对齐,因此也叫Byte-Stream Format。用于mp4/flv/mkv, VideoToolbox。
  • Annex-B格式 也叫MPEG-2 transport stream format格式(ts格式), ElementaryStream格式。 这两种方式有什么异同点,或者哪个更好用,这里用一个表格列一下
AVCC格式Annex-B
参数集格式放在专门结构体extradata中,如果是mp4封装,在轨道stsd盒子中能够找到直接用start_code分隔,放在I帧前面
start_code没有,只有固定字节长度的描述nalu长度字段(一般是4个字节,也有可能是3个字节)0x000001或0x00000001,一般是四字节

解码器接入

按照一般的理解,获取到了I帧数据,我们就能完整的解析出一帧图像,但是这个工作并没有那么容易,因为里面使用的压缩算法,由于我们准备用nodejs环境,移植其它平台的解码函数并非不可能实现,但也有很多工作要做。现在有一种技术,webassembly,原理大概就是将其它平台的代码编译成js环境能够运行的字节码,这样不仅效率高,而且使用便捷,具体用法还没有深入研究过。

我手上拿到的视频基本上是avcc的格式,但对于解码器,支持程度不一,在这个项目里,使用了一个第三方的包,video-decoder。这个包使用了wasm,调用了ffmpeg,这里只是用来对关键帧解码,但是经过测试,只支持annexb格式。因此,需要一个类似于ffmpeg中的h264_mp4toannexb函数,就以它命名吧,重新在nodejs下构造了一下,不过这是个简化版的,只转码一个完整的视频单元。 h264 avvc格式转为annexb格式,便于后续解码器处理 输入必须为h264流,不能是MP4chunk,因为chunk之间有间隙,不一定连续 将关键帧的sample作为参数,返回annexb格式的一个完整的nalu序列(sps+pps+I),这样,后续解码器就能解出关键帧图像,这里给出h264_mp4toannexb函数

function h264_mp4toannexb(bufferStream = new Buffer() , NaluHeaderLength = 4){
	let offset = 0  //定义一个游标
	let sps_length = 0;
	let pps_length = 0;
	let data_length = 0;
	if(bufferStream.length == 0){
		return false
	}
	const start_code  = Buffer.from([0x00,0x00,0x00,0x01])
	const start_code_short = Buffer.from([0x00, 0x00, 0x01])
	let newBufferStream = Buffer.from([])
	const PB_header = Buffer.from([0x01,0x9a,0x24,0x18])
	// 长度不知 需要计算出来
	// 根据nalu_header长度,计算出大小
	if(NaluHeaderLength == 4){
		
		while(offset<bufferStream.length-4){
			// 这里是明确知道,这是帧开头,并且有4个字段的长度描述信息
			// avcc是没有分隔符的,所有描述信息都在moov表中
		   // 这里有2种情况,关键帧,前面带sps,pps,接着是data,不是关键帧,直接是length + data
			let naluType = bufferStream[offset + 4] % 16
			if(naluType === 7){
				//如果是SPSnalu
				sps_length = bufferStream.slice(offset,offset + 4).readUInt32BE(0)
				offset = offset + 4
				let spsNalu = bufferStream.slice(offset, offset + sps_length)
				//console.log('sps_length:',sps_length, spsNalu)
				offset = sps_length + offset
				

				pps_length = bufferStream.slice(offset,offset + 4).readUInt32BE(0)
				offset = offset + 4
				let ppsNalu = bufferStream.slice(offset, offset+ pps_length)
				offset = offset + pps_length
				data_length = bufferStream.slice(offset,offset + 4).readUInt32BE(0)
				offset = offset + 4
				if(offset + data_length < bufferStream.length){
					//没到buffer结尾
					let IdrNalu = bufferStream.slice(offset, offset + data_length)
					offset = offset + data_length

					newBufferStream = Buffer.concat([newBufferStream,start_code, spsNalu, start_code , ppsNalu, start_code, IdrNalu])
				}else{
					//到buffer结尾了
					let otherNalu = bufferStream.slice(offset)//从I帧数据之后直到结尾,不应该添加其它信息,而是加一个p帧头
					newBufferStream = Buffer.concat([newBufferStream,start_code,otherNalu])
					offset = bufferStream.length
				}
				
			}else if(naluType === 5 || naluType === 1){
				//如果是关键帧 或者 P帧/B帧
				data_length = bufferStream.slice(offset,offset + 4).readUInt32BE(0)
				offset = offset + 4
				if(offset + data_length < bufferStream.length){
					//没到buffer结尾
					let IdrNalu = bufferStream.slice(offset, offset + data_length)
					offset = offset + data_length
					// 区分I帧和P帧 p帧是加上3字段的start_code,slice 也是3字段的
					if(naluType === 1){
						newBufferStream = Buffer.concat([newBufferStream,start_code, IdrNalu])

					}else{
						newBufferStream = Buffer.concat([newBufferStream,start_code, IdrNalu])

						// 找到I帧了,直接加上一个P帧头就返回
						return Buffer.concat([newBufferStream,start_code,PB_header])
					}
				}else{
					//到buffer结尾了
					let otherNalu = bufferStream.slice(offset)//从I帧数据之后直到结尾
					newBufferStream = Buffer.concat([newBufferStream,start_code,otherNalu])
					offset = bufferStream.length
				}
			}else {
				console.log('转换结束')
				//如果是其它情况,可能是chunk间隙,数据乱七八糟。加个分隔符结束掉,并且要在后面跟一个P帧标识,这样后面的解码器会认为这是完整的一帧
				let otherNalu = bufferStream.slice(offset)//从I帧数据之后直到结尾
				newBufferStream = Buffer.concat([newBufferStream,start_code,PB_header])
				offset = bufferStream.length
			}
		}
		 return newBufferStream
	}

}

解码器的使用也比较简单,包主页给出了用法示例

import Decoder from 'video-decoder'

Decoder.setReadyCb(() => {
    const de = new Decoder('h265')    // 可选 h264/h265
    de.put(buf)     // buf 需要是 Uint8Array 类型
    // get: 取出一帧数据(如果有的话, 否则返回 null )
    // 一个对象,包含 width、height、data
    // 其中 data 是 Uint8Array 类型,RGBA,可以直接复制到 ImageData 对象中
    de.get()        
    de.dispose()    // 不再使用后要释放资源
})

图像导出

上面已经走完了基本流程,理论上解码已经走通,需要做的就是将解码后的RGB数据保存成图片,这里我们又用到另外一个包jpeg-js,使用方法也超级简单

var jpeg = require('jpeg-js');
var jpegImageData = jpeg.encode(rawImageData, 50);
console.log(jpegImageData);
fs.writeFileSync('image.jpg', jpegImageData.data);

这样关键帧就导出为图片啦!

操作演示

step1 分析mp4解构

这里用的一个样例MP4 video_.mp4,经过mp4 reader 分析,得到参数如下

名称内容
文件名video_2.mp4
文件大小1886857
编码格式avcc
chunck1 offset16120
关键帧 sample1 offset16120
sample1 size20504
sps0x67 0x64 0x00 0x1f 0xac 0xd9 0x40 0x94 0x0a 0x1e 0xb8 0x40 0x00 0x00 0x03 0x00 0x40 0x00 0x00 0x0f 0x03 0xc6 0x0c 0x65 0x80
pps0x68 0xef 0xbc 0xb0
NAL Unit length size4
chunck1 sample数3
sample2 size2102
sample3 size291
关键帧 sample251 offset1373800
sample251 size47214

step2 解码

根据上面步骤,知道了关键帧sample1偏移量是16120,读取一帧数据到buffer中,

const MP4_FRAME_MAX_LEN = 1024 * 1024 //定义最大帧长度
let buff = Buffer.alloc(MP4_FRAME_MAX_LEN)
let filehandle =  await fsPromise.open(filename)
let { buffer , bytesRead} =await filehandle.read(buff, 0, MP4_FRAME_MAX_LEN, sample1_offset )

然后进行格式转换,将avcc转为annxeb格式,

let annexbNalu =   h264_mp4toannexb(buffer)

然后送入解码器解出图像

Decoder.setReadyCb(async () => {
   const de = new Decoder('h264')    // 可选 h264/h265
   de.put(annexbNalu)     // buf 需要是 Uint8Array 类型
   let rawImageData = de.get() 
   if(rawImageData){
     var jpegImageData = jpeg.encode(rawImageData, 50);
     fs.writeFileSync(`${filename}-out-00.jpg`, jpegImageData.data);
   }else{
     console.log('解码失败1')
   } 
  de.dispose() 
})

step3 保存图片

 var jpegImageData = jpeg.encode(rawImageData, 50);
 fs.writeFileSync(`${filename}-out-00.jpg`, jpegImageData.data);

这样图片就保存到本地了

image.png

这个项目并不完善,后续将会继续推进,我已将源码上传到码云,有兴趣的同学可以验证一下。视频截帧作用也比较大,除了作为视频封面外,还可用于安防监控等场景,阿里系还提供了oss视频截帧的服务,说明市场市场需求还是有的。(完)