持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
前面几篇文章介绍了部分关于从mp4文件获取信息的一些方法,包括计算时长和导出首帧图片:
- nodejs环境下如何获取MP4视频时长(一)
- nodejs环境下如何获取MP4视频时长(二)
- nodejs环境下对h264编码mp4的关键帧导出探讨(一)
- nodejs环境下对h264编码mp4的关键帧导出探讨(二) 这次索性一步到位,直接移植一个mp4解析器,这样后续的解码就容易了。
why
之前在获取mp4关键帧的时候,我们是通过mp4reader这款软件进行分析的,这是window上的软件,在nodejs上毕竟有些不便,不如将其移植过来,手写一个MP4解析包吧。有人说nodejs虽然是后端,但是不适合做计算密集型任务,比如视频解码🎞,虽说不适合,但也不是不可以,之前的文章就是一种尝试。而且,node有了Buffer的加持,操作十六进制文件简直so easy😎。
what
知己知彼,方能百战不殆。做mp4解析,自然要先学mp4规范。用度娘百科了一下,差点惊掉了下巴😲,竟然有二十七部分内容,仔细琢磨了一下,好在关于文件格式的定义,主要集中在第十二部分(ISO/IEC 14496-12)。互联网上关于MP4结构的分析,大都也是围绕这一部分。学习完之后,用百度脑图画了一张结构图,黄色标点部分是还没有做的,对于截帧来说也已足够用。我也是边学边记,各类修正版本也比较多,可能不是很全,姑且先这样吧,后续再补充。
HOW
前人栽树,后人乘凉。先去npm上搜一下,看看是否前人已栽了树。
关键字: mp4 相关包471个
点开看看第一个包:mp4,
看简介,
A Pure Javascript Mp4 Container Parser Based On ISO_IEC_14496-12
根据第十二部分解析mp4容器,这不就是了吗。再看Notice,心凉凉,
This package is still incubating and is now some what functional
这位大佬6年前的树还正在长大中,还不能乘凉。
另一比较接近的是包是mp4box,但是结构上并不与mp4文件一致。加了一些定制化的功能,但是下载量还是挺大的,说明还是有这方面的需求。既然这样,那就继续那位大佬未完成的工作吧。
step1 确定moov
的位置
看文章开头那个结构图,显而易见,大部分描述信息都在moov
盒子中,前面文章也说过,moov
这个盒子位置不固定,这是最要命的一点😶,还有一个包专门为了解决这个问题(传送门🛸)。我们的首要任务是就是找到moov
盒子的位置📦。
思路同nodejs环境下如何获取MP4视频时长(二)一文中的类似,开辟一块内存进行操作,从文件头到文件尾扫描一遍。这里设置了MAX_BUFFER_LEN = 1MB,由于在内存中查找,速度并不慢,看一下代码:
// 找到moov盒子
async function Mp4FindMoov(filename = ''){
if(filename == ''){
return -1
}
// filename = path.resolve(filename);
let offset = 0
const MAX_BUFFER_LEN = 1024 * 1024 //1MB 空间
let buff = Buffer.alloc(MAX_BUFFER_LEN) //共享内存
for(let k =0 ;k < MAX_BUFFER_LEN * 1000; k++){
let filehandle = await fsPromise.open(filename)
let { buffer , bytesRead} =await filehandle.read(buff, 0, MAX_BUFFER_LEN, offset )
filehandle.close()
if(bytesRead == 0){
return -1 //'没找到'
break
}else{
let result = buffer.indexOf('moov')
if(result > 0){
return {
offset:offset + result - 4,
size:buff.readUInt32BE(result-4),
}
}else{
// 继续下一个循环
}
}
offset += MAX_BUFFER_LEN
}
}
返回 -1 表示错误。如果顺利,将返回moov
在文件中的偏移量及大小。
step2 逐层解析,各个击破
找到moov后,直接将相关数据读到内存中进行操作。
let filehandle = await fsPromise.open(filename,'r')
let buff = Buffer.alloc(size)
let { buffer:moov_buffer , bytesRead} =await filehandle.read(buff, 0, size, offset )
filehandle.close()
if(bytesRead == 0){
return -1 //'没找到'
}
这里开辟的内存大小就是moov
盒子的大小,这样,整个盒子的数据都在内存中了。
接下来,就是像剥洋葱一样🧅,父盒子会调用子盒子上的解析函数 ,再将返回值合并一起返回,这一思路是从mp4包的大佬那里瞄来的。
最外层的方法是
//解析moov盒子
async function Mp4DecodeMoov(filename = ''){
let moovInfo = await Mp4FindMoov(filename)
if(moovInfo == -1){
// console.error('cannot find moov box')
return -1
}
// Object.assign(mp4Info.moov , moovInfo)
let {size = 0, offset = 0} = moovInfo
if(filename == '' || size === 0){
// console.error('mp4 moov box info error')
return -1
}
let filehandle = await fsPromise.open(filename,'r')
let buff = Buffer.alloc(size)
let { buffer:moov_buffer , bytesRead} =await filehandle.read(buff, 0, size, offset )
filehandle.close()
if(bytesRead == 0){
return -1 //'没找到'
}
let mvhd = parse_mvhd(moov_buffer, offset)
let udta = parse_udta(moov_buffer, offset)
let trak = parse_trak(moov_buffer, offset)
return {
...moovInfo,
mvhd: mvhd,
udta:udta,
trak:trak
}
}
parse_mvhd(moov_buffer, offset)
、
parse_udta(moov_buffer, offset)
、
parse_trak(moov_buffer, offset)
这三个方法,就是分别解析moov的三个子盒子mvhd
udta
trak
的,其它的代码也是类似,就是逐层调用。其中,trak
是轨道盒子比较特殊,可能有多个,这里直接通过对象数组的形式返回,我们来看一下代码:
var parse_trak = function(moov_buffer = Buffer.from([]),base_offset = 0) {
let trak = []
// console.log("TRAK");
// 这里比较特殊,可能有多个trak,必须找出所有trak
let trak_offset_list = []
for(let i = 0;i< moov_buffer.length;){
let offset = moov_buffer.indexOf('trak',i)
if(offset < 0){
break;
}
let size = moov_buffer.readUInt32BE(offset -4 )
trak_offset_list.push(offset)
i = offset + size - 4
}
// console.log('trak_offset_list',trak_offset_list)
trak_offset_list.forEach(ele=> {
let offset = ele
offset -= 4
let size = moov_buffer.readUInt32BE(offset ) //read 4 bytes unpacked N
let box_type = moov_buffer.slice(offset+4, offset + 8).toString() //read 4 bytes
// 8 Bytes reserved;
let tkhd = parse_tkhd(moov_buffer.slice(offset) ,offset + base_offset)
let mdia = parse_mdia(moov_buffer.slice(offset) ,offset + base_offset)
trak.push({
"Start_offset": offset + base_offset,
"Box_size": size,
"Box_type": box_type,
tkhd:tkhd,
mdia:mdia
})
});
return trak
}
针对多个trak,直接扫描了一遍buffer,将trak
盒子的偏移量push到trak_offset_list
这个数组中,再通过循环,逐个解析trak并最后返回整个数组trak
。
完整的解析方法调用层级为:
- parse_ftyp
- Mp4DecodeMoov
-
- parse_mvhd duration/scale 时长 标尺等信息
-
- parse_trak
-
-
- parse_tkhd: track_id/duration/width/height
-
-
-
- parse_mdia
-
-
-
-
- parse_mdhd
-
-
-
-
-
- parse_hdlr: Handler_type/Name 编码类型
-
-
-
-
-
- parse_minf
-
-
-
-
-
-
- parse_dinf
-
-
-
-
-
-
-
- parse_stbl
-
-
-
-
-
-
-
-
- parse_stsd 不同编码类型,对应不同方法,目前支持 avc1|mp4a|hev1
-
-
-
-
-
-
-
-
-
-
- parse_avc1 width/height/Horiz_resolution/Ver_resolution/Frame_count
-
-
-
-
-
-
-
-
-
-
-
-
- parse_avcC SPS/PPS/NALU_length_size !对解码非常重要
-
-
-
-
-
-
-
-
-
-
-
-
- parse_mp4a
-
-
-
-
-
-
-
-
-
-
-
- parse_hev1
-
-
-
-
-
-
-
-
-
-
- parse_stts Time_to_sample:array
-
-
-
-
-
-
-
-
-
- parse_stss Sample_list:array,“stss”确定media中的关键帧。
-
-
-
-
-
-
-
-
-
- parse_ctts Sample_lsit:array,记录帧偏差,用以恢复时间戳
-
-
-
-
-
-
-
-
-
- parse_stsc Sample_to_chunk:array,sample与chunk关系表
-
-
-
-
-
-
-
-
-
- parse_stsz Sample_size_list:array,“stsz” 定义了每个sample的大小
-
-
-
-
-
-
-
-
-
- parse_stco Chunk_offset_list,“stco”定义了每个chunk在媒体流中的位置
-
-
-
-
-
- parse_vmhd
完整的方法,已经打包上传到npm,感兴趣的小伙伴npm i mp4reader
即可使用。示例代码:
//test.js
let {Mp4DecoderAll } = require('mp4reader')
async function test(filename){
try{
let mp4Info = await Mp4DecoderAll(filename)
console.dir(mp4Info, { depth: null})
}catch(err){
console.log(err)
return
}
}
test('test.mp4') //替换为自己的文件名称
执行一下,即可看到打印结果。部分结果截图如下所示:
如图所示,已经比较完整地解析出了mp4的结构,这之后,就可以分解出NALU单元,进而进行解码操作了。
总结
以上工作是近期对MP4集中学习的记录,任何一个领域,都不敢说轻易二字,原本计划2天写完,前前后后磨了一个星期,其中不免还有一些错误,同时也要感谢雷神等大佬前辈的无私分享。后面还有一些工作要做,比如模块化的导出、任意时间的NALU、h264流解码等。
本篇分享就到这里😉,感谢点赞评论收藏。