1.背景
在web端处理音视频是一个复杂而又重要的课题,市场上主流的视频编辑通常采用服务端进行渲染导出,因为专用的服务器对音视频的编解码能力更强,所以服务端渲染导出的速度很不错;
少数编辑器在浏览器本地对视频进行处理,一方面对服务器成本非常友好,另一方面可以不需要注册等流程,在小型视频的渲染上用户体验更好。但是浏览器本地渲染对用户设备有一定要求,对浏览器的兼容性等等也有要求。
而经典的在浏览器本地处理视频的方案是通过ffmpeg.wasm
,近些年Webcodecs API
的出现与普及逐渐改变了这一现象。
ffmpeg.wasm
的底层webassembly对ffmpeg多线程处理视频的兼容很差,GPU调用效果也不尽人如意,导致渲染视频的速度非常不理想,并且还要额外下载编解码器,整体使用体验存在很多不适。
而WebCodecs API
可以利用浏览器自带的FFmpeg,而且可以充分利用GPU,所以其执行效率是远高于webassembly的。
(该图取自Bilibili团队实验[1])
1.1功能对比
特性/功能 | ffmpeg.wasm | WebCodecs |
---|---|---|
目标 | 在浏览器中实现音视频处理 | 提供底层音视频操作API |
技术基础 | 基于WebAssembly技术 | 基于现代Web浏览器API |
跨平台兼容性 | 跨平台兼容性较好 | 跨平台兼容性一般 |
应用场景 | 实时视频预览、截图、教育与教程、社交媒体、数据分析 | 实时通信、视频编辑、自适应流媒体、机器学习 |
操作层级 | 提供简单的JavaScript API,方便集成到Web应用 | 更深入地控制媒体流,实现高效、低延迟的实时通信或视频编辑 |
前端集成难度 | 提供了简单的JavaScript API,集成相对容易 | 需要一定的音视频处理知识,集成难度可能稍高 |
2.WebCodecs 介绍
如果要问WebCodecs是什么,可以简单的概括为JavaScript赋予了通过浏览器底层对视频流的单个帧和音频数据块的底层访问能力的一项web技术。
简单地说,就是设置一个解码器
,将视频编码字节块处理为视频帧/音频数据
,或者反之,设置一个编码器
,将视频帧/音频数据
处理回编码字节块。
上文所说的WebCodecs API的解码器有:
名称 | 介绍 |
---|---|
AudioDecoder | 解码 EncodedAudioChunk 对象 |
VideoDecoder | 解码 EncodedVideoChunk 对象 |
上表中EncodedAudioChunk
和EncodedVideoChunk
就是上文提到的编码字节块。
上文所说的WebCodecs API的编码器有:
名称 | 介绍 |
---|---|
AudioEncoder | 编码 AudioData 对象 |
VideoEncoder | 编码 VideoFrame 对象 |
上表中AudioData
和VideoFrame
就是上文提到的视频帧/音频数据
。
请注意,WebCodecs API并不提供对某一视频类型具体的编解码器,解码视频时,你需要自行将这个视频转为EncodedVideoChunk
和EncodedAudioChunk
,再交由WebCodecs API进行处理。渲染合成视频同理。
常见的方案有Mp4Box.js。
3.WebCodecs 支持情况
WebCodecs在Chrome 94上得到支持,下面是一个可供参考的浏览器支持表。
浏览器 | 支持情况 | 发布时间 |
---|---|---|
Chrome | 94+ | 2021-09-21 |
Edge | 94+ | 2021-09-24 |
Firefox | 不支持 | |
Opera | 80+ | 2021-10-05 |
Safari | 16.4+ | 2023-03-27 |
360 浏览器 | 14+ | 2022-11 |
QQ 浏览器 | 12+ | 大约 2023-09-05 之后 |
2345 浏览器 | 12+ | 未查到 |
Chrome Android | 94+ | 2021-09-21 |
Firefox for Android | 不支持 | |
Opera Android | 66+ | 2021-12-15 |
Safari on iOS | 16.4+ | 2023-03-27 |
可以看到,不少浏览器的在23年才提供支持。
可用如下代码进行判定浏览器是否支持:
if('VideoEncoder' in window){
console.log("webcodecs is supported.")
}
4.视频播放原理
众所周知,视频由画面和音频构成。而画面由一帧一帧的图像组成,音频由一段一段的声波构成。按照某个频率不断地同步切换帧和声波,就可以实现视频的播放。
但是,视频并不会完整的将每一帧以图片的形式进行保存,而是通过一些复杂的结构,将视频的画面进行压缩,并将时长等元数据整合到一起,形成一个完整地视频文件。
下面介绍下视频文件的结构。
5.视频结构
HTML5提供了HTMLMediaElement,可以直接使用HTML标签播放视频音频,而对于m3u8或Flash时代留存的大量Flv视频,也有例如FLV.js等相应的库,使其可以被HTMLMediaElement播放。
这些高度封装的库也使得我们对视频文件的结构比较陌生,这里以最常见的MP4格式简单介绍一下。
5.1视频的编码
视频编码是将原始视频数据转换为压缩格式的过程,以减小文件大小并提高传输效率。
编码的目的是为了压缩,不同的编码格式则对应不同的压缩算法。
MP4文件常用的编码格式有H.264(即AVC)、H.265(HEVC)、VP8、VP9等。
H.265在市场上有很高的占有量,但因为其高昂的授权费用,免费的AV1编码正逐步被市场接纳。
5.2视频的封装
视频编码后,将其和文件的元数据封装到容器格式中,以创建完整的视频文件。
压缩后的原始数据,需要有元数据的配合才能被解析播放;
常见的元数据包括:时间信息,编码格式,分辨率,作者,标题等等。
5.3动态补偿与帧间压缩
对视频进行二次压缩,无需掌握具体算法。
动态补偿指的是,连续的两帧之间有相同的部分,只是位置发生了变化,所以第二帧可以只储存偏移量
帧间压缩是对两帧之间进行diff,第二帧只储存diff运算出的不同的那一部分
5.4帧的类型
根据上面的过程,帧之间相互可能并不独立,于是产生了三种帧类型
I帧:也就是关键帧,保留完整的画面信息,没有被二次压缩,可以被独立还原为图像
P帧:依赖前一帧的解码结果才能还原为图像
B帧:依赖前一帧与后一帧的解码结果才能还原为图像,但占用空间一般最少
6.Demo
前面介绍了非常多的Webcodecs和视频相关的概念,我们来做一个小的demo,利用MP4Box.js作为编解码器,尝试解析一个视频。
先放一个Demo 地址:https://codesandbox.io/p/devbox/nifty-dawn-gghryr?embed=1&file=%2Findex.js%3A111%2C1
(代码基于张鑫旭blob修改[2])
6.1解析部分
我们先创建一个Mp4box实例:
const mp4box = MP4Box.createFile();
Webcodecs基于Stream的思想,所以我们需要用Stream去提供数据。比较简单的方法是用fetch去请求:
fetch(mp4url)
.then((res) => res.arrayBuffer())
.then((buffer) => {
state.innerHTML = "开始解码视频";
buffer.fileStart = 0;
mp4box.appendBuffer(buffer);
mp4box.flush();
});
请注意,mp4box.appendBuffer接受ArrayBuffer类型的数据。
加载小型视频时,可以直接用上面的代码。但若是视频较大,上面的代码效率就不太够看。可以用reader.read().then(({ done, value })
替代,但是要注意,这样获取的data
是Unit8Array类型,需要手动转为ArrayBuffer,并且要修改buffer.fileStart为这一段data的起点。
然后,我们对mp4box进行监听,当文件开始解码会首先触发onMoovStart
(Demo中未用到),这里的Moov可能不好理解,他指的是"Movie Box"
,也被称为 "moov atom"
,包含了视频文件的关键信息,如视频和音频的媒体数据、时长、轨道信息等。
当moov解析完成,会触发onReady
,onReady
会将视频的详细信息也就是moov传给回调函数的第一个参数。详细的数据结构可以参考Mp4Box.js官方文档:地址
我们姑且叫这个信息为info,这里面我们在意的参数是轨道info.videoTracks
。他是一个数组,包含了这个轨道的采样率、编码方法等等信息,一般长度是2,第0个是视频轨道,第1个是音频轨道。(不过例如专业电影等更复杂的视频可能会有更多轨道,这里不做考虑)
我们将轨道拉出来,扔到下面的萃取环节中。
6.2萃取部分
这是一个非常形象的名称,在官方文档里叫做Extraction
,它用来提取轨道并进行采样。
我们在onReady过程中,设置了Extraction
的参数:
mp4box.setExtractionOptions(videoTrack.id, "video", {
nbSamples: 100,
});
第二个参数指的是user,指的是此轨道的分段调用方,将会被传到后面介绍的onSamples中,可以是任意字符串,表示唯一标识
第三个参数中nbSamples表示每次回调调用的样本数。如果收到的数据不足以提取样本数量,则保留迄今为止收到的样本。如果未提供,则默认值为 1000。越大获取的帧数越多。
当一组样本准备就绪时,将根据 setExtractionOptions
中传递的选项,启动onSamples
的回调函数。
mp4box.onSamples = function (trackId, ref, samples) {
//......
}
onSamples
会给回掉传入三个参数:trackId, ref, samples
分别代表轨道id,user,上一步采样的样品数组。
通过遍历这个数组,将样品编码成EncodedVideoChunk
数据:
for (const sample of samples) {
const type = sample.is_sync ? "key" : "delta";
const chunk = new EncodedVideoChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data,
});
videoDecoder.decode(chunk);
}
其中sample.is_sync
为true,则为关键字。然后将EncodedVideoChunk
送入videoDecoder.decode
进行解码,从而获取帧数据。
videoDecoder
是在onReady中创建的:
videoDecoder = new VideoDecoder({
output: (videoFrame) => {
createImageBitmap(videoFrame).then((img) => {
videoFrames.push({
img,
duration: videoFrame.duration,
timestamp: videoFrame.timestamp,
});
state.innerHTML = "已获取帧数:" + videoFrames.length;
videoFrame.close();
});
},
error: (err) => {
console.error("videoDecoder错误:", err);
},
});
其本质还是使用Webcodecs API的VideoDecoder,在接受onSamples
送来的数据后,解码为videoFrame
数据。此时的操作可根据业务来,Demo中将他送到createImageBitmap
转为位图,然后推入videoFrames
中。
在Demo的控制台中打印videoFrames
,即可直接看到帧的数组。
我们可以在页面上再创建一个canvas,然后ctx.drawImage(videoFrames[0].img,0,0)
即可将任意一帧绘制到画面上。(Demo里没有加canvas,大家可以在控制台自己加)
6.3音频
音频的操作与视频类似,onReady中的info也有audioTracks属性,从里面取出来并配置Extraction
:
if (audioTrack) {
mp4box.setExtractionOptions(audioTrack.id, 'audio', {
nbSamples: 100000
})
}
配置音频解码器AudioDecoder
:
audioDecoder = new AudioDecoder({
output: (audioFrame) => {
console.log('audioFrame:', audioFrame);
},
error: (err) => {
console.error('audioDecoder错误:', err);
}
})
const config = {
codec: audioTrack.codec,
sampleRate: audioTrack.audio.sample_rate,
numberOfChannels: audioTrack.audio.channel_count,
}
audioDecoder.configure(config);
其他操作不再赘述,可以在Demo的AudioTest.html
中查看。
可以发现,最后获取到的数据与上文,视频帧的结构非常类似,是AudioData
数据结构,可以将他转换为Float32Array
,就可以进行对音频的各种操作了。
视频的单位是帧,音频的单位可以说是波,任意一个波可以用若干个三角函数sin、cos之和表示。
根据高中物理,波可以相加。我们可以将两个Float32Array
每一项相加,实现两个声波的混流:
function mixAudioBuffers(buffer1, buffer2) {
if (buffer1.length !== buffer2.length) {
return
}
const mixedBuffer = new Float32Array(buffer1.length);
for (let i = 0; i < buffer1.length; i++) {
const mixedSample = buffer1[i] + buffer2[i];
mixedBuffer[i] = Math.min(1, Math.max(-1, mixedSample));
}
return mixedBuffer;
}
上述代码进行了归一化,避免求和的值超过1,而这里的波的振幅范围是[-1,1]。更常见的做法是提前对波进行缩放。
此外,我们可以通过改变波的振幅来修改音量,只需要把Float32Array
的每一项*2即可放大两倍音量:
function increaseVolume(audioBuffer, volumeFactor) {
const adjustedBuffer = new Float32Array(audioBuffer.length);
for (let i = 0; i < audioBuffer.length; i++) {
const adjustedSample = audioBuffer[i] * volumeFactor;
adjustedBuffer[i] = Math.min(1, Math.max(-1, adjustedSample));
}
return adjustedBuffer;
}
可以参考:AudioData 文档,摸索更多有意思的操作。
7.可能的应用场景
在上传视频的场景截取视频封面 轻量级视频剪辑 封装不易被爬虫的视频播放器
8.部分参考资料
https://www.bilibili.com/read/cv30358687/ https://www.zhangxinxu.com/study/202311/js-mp4-parse-effect-pixi-demo.php https://developer.mozilla.org/en-US/docs/Web/API/AudioData https://zhuanlan.zhihu.com/p/648657440