使用nodejs解析mp4文件-致敬MP4Reader

1,067 阅读6分钟

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

前面几篇文章介绍了部分关于从mp4文件获取信息的一些方法,包括计算时长和导出首帧图片:

why

之前在获取mp4关键帧的时候,我们是通过mp4reader这款软件进行分析的,这是window上的软件,在nodejs上毕竟有些不便,不如将其移植过来,手写一个MP4解析包吧。有人说nodejs虽然是后端,但是不适合做计算密集型任务,比如视频解码🎞,虽说不适合,但也不是不可以,之前的文章就是一种尝试。而且,node有了Buffer的加持,操作十六进制文件简直so easy😎。

what

知己知彼,方能百战不殆。做mp4解析,自然要先学mp4规范。用度娘百科了一下,差点惊掉了下巴😲,竟然有二十七部分内容,仔细琢磨了一下,好在关于文件格式的定义,主要集中在第十二部分(ISO/IEC 14496-12)。互联网上关于MP4结构的分析,大都也是围绕这一部分。学习完之后,用百度脑图画了一张结构图,黄色标点部分是还没有做的,对于截帧来说也已足够用。我也是边学边记,各类修正版本也比较多,可能不是很全,姑且先这样吧,后续再补充。

mp4文件结构.svg

HOW

前人栽树,后人乘凉。先去npm上搜一下,看看是否前人已栽了树。 关键字: mp4 相关包471个 image.png

点开看看第一个包:mp4,

image.png 看简介,

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') //替换为自己的文件名称

执行一下,即可看到打印结果。部分结果截图如下所示:

image.png

如图所示,已经比较完整地解析出了mp4的结构,这之后,就可以分解出NALU单元,进而进行解码操作了。

总结

以上工作是近期对MP4集中学习的记录,任何一个领域,都不敢说轻易二字,原本计划2天写完,前前后后磨了一个星期,其中不免还有一些错误,同时也要感谢雷神等大佬前辈的无私分享。后面还有一些工作要做,比如模块化的导出、任意时间的NALU、h264流解码等。

本篇分享就到这里😉,感谢点赞评论收藏。