持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情
前面文章介绍了mp4和h264编码的一些知识点,为我们顺利找到关键帧的数据提供了途径。这一部分,我们将着重解决从关键帧数据中还原图像。 现在在我们面前的是第一个sample的位置和长度信息,我们顺利的找到了一段十六进制数据,在正式开始之前,还有几个问题需要理清楚,这也是本人在实际操作过程中掉进去的坑。
- h264编码的封装格式问题
- 解码器的接入
- 图像导出
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 offset | 16120 |
关键帧 sample1 offset | 16120 |
sample1 size | 20504 |
sps | 0x67 0x64 0x00 0x1f 0xac 0xd9 0x40 0x94 0x0a 0x1e 0xb8 0x40 0x00 0x00 0x03 0x00 0x40 0x00 0x00 0x0f 0x03 0xc6 0x0c 0x65 0x80 |
pps | 0x68 0xef 0xbc 0xb0 |
NAL Unit length size | 4 |
chunck1 sample数 | 3 |
sample2 size | 2102 |
sample3 size | 291 |
关键帧 sample251 offset | 1373800 |
sample251 size | 47214 |
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);
这样图片就保存到本地了
这个项目并不完善,后续将会继续推进,我已将源码上传到码云,有兴趣的同学可以验证一下。视频截帧作用也比较大,除了作为视频封面外,还可用于安防监控等场景,阿里系还提供了oss视频截帧的服务,说明市场市场需求还是有的。(完)