背景
Web视频编辑器即基于浏览器的视频编辑工具,可以实现视频拼接、滤镜等功能点,一般包含视频导入、预览和导出功能。在众多编辑器中,绝大部分编辑器选择了服务端导出,因为服务端编解码能力更强,导出速度更快,而且可以在用户关闭浏览器后离线导出。唯一可能存在的问题就是所见即所得,导出和预览可能存在细微差别。
一些用户存在视频编辑的需求,如自我介绍视频、party邀请函等,对于这种临时的、少量的需求,用户可能既不想付费,也不想走下载、注册这套流程。一个免费的、纯前端的web编辑器就可以满足用户的需求。
市面上少数编辑采用了前端导出,虽然在导出速度上不占优势,所需时间往往约等于视频时长,但因为不占用服务器资源,更容易打出免费的口号吸引用户。而在前端导出的编辑器中,更多以FFMpeg+Wasm方案为主。
本文将探索使用WebCodecs来实现前端的导出,并优化导出速度,在720P下,使导出时间仅需视频时长的1/4到1/3;并且因为前端提前缓存了编辑的素材,产物也是保存在前端,节省了素材缓存和产物下载的时间,在短视频场景下,同服务端导出相比,前端导出可以更快。
视频本身的编辑技术,不在本文的讨论范围。
技术简介
什么是WebCodecs
Web Codecs是浏览器提供的一套音视频编解码的API。相比较于我们自行编写的wasm进行软编软解,Web Codecs可以利用浏览器自带的FFmpeg,其执行效率是高于webassembly的,而且可以充分利用GPU。但其缺点就是编解码的兼容性较差,对于浏览器未支持或者未开放的编解码格式,都无法进行处理。关于WebCodecs的使用其实非常简单,有很多文章可以参考,基本上就是配置一个编码器(或者解码器),喂给它视频帧(或者编码块),得到编码块(或者视频帧)。关于这部分的基础API如何使用,建议直接看官方文档github.com/w3c/webcode…
编码与封装
我们拿到一个视频,其后缀名一般决定了其封装格式,如mp4,webm,flv,它决定的仅仅是音视频格式如何组装到一起。对视频进行解封装后就能拿到其编码格式和编码数据,常见视频编码格式有H.264,H.265,VP8,VP9等,对编码数据进行解码后,就可以得到原始的视频帧数据。以上是播放过程,需要进行解封装和解码。如果要将视频导出,则需要进行相反的操作,先得到原始的视频帧数据,再进行编码,最后进行封装。
I帧、P帧和B帧
I帧被称为关键帧,在解码时,一个I帧就可以得到一个完整的视频帧。P帧是前向参考帧,它需要在I帧的基础上进行解码,才能得到视频帧,单独对P帧无法完成解码。B帧是双向预测编码帧,它不仅要得到前面的解码帧,还要得到后面的解码帧,单独的B帧无法单独完成解码,在存在B帧的情况下,帧的解码顺序与播放顺序是不一致的。
GOP
两个I帧之间,被称为一个GOP,是一组连续的画面。通常一个GOP是一个解码单元。
Annex-B与 AVCC
两种不同的H264格式,详见Annex-B与 AVCC
架构分析
视频由一帧帧的图片组成,对视频的编辑实际上就是对每一帧图片的编辑。我们可以粗略的将视频编辑的步骤按下图进行划分。
视频帧本质上就是一张图片。从素材得到视频帧,就是一个将素材转化为图片的过程,素材可能是一张图片、一段文字或者一个视频。其中最复杂的情况就是由视频得到图片,也就是视频抽帧。
从视频帧得到编辑后的视频帧是编辑的步骤,可以用WebGL去实现,比如Pandam,也可以用WebAssembly实现,如VESDK,本文不去展开讨论。
将编辑后的视频帧渲染在页面canvas上,就是预览,这个步骤相对简单,本文也不去展开。
将编辑后的视频帧导出为视频文件就是导出,从一帧帧的图片得到一个可播放的视频,这个步骤又可以被细分为编码和封装。
当然一个视频的处理还包含音频部分,不过相对于视频,其会简单很多。
下面将着重介绍视频图像抽帧、图像导出、音频导出。细分后的框架如下
抽帧
定时器抽帧
在预览场景下,我们最常用的抽帧方案就是定时器,用setTimeout或者requestAnimationFrame来保证预览的流畅。但是在导出场景下此方案存在两个问题:
- 需要将视频播放完毕,才能抽帧完毕,所以总体的导出时长必然大于视频时长
- 定时器执行时机不确定,导致存在丢帧的可能
当然,我们可以通过视频倍速播放缩短抽帧时间,但是倍速之后,对两次抽帧的间隔要求更严格,更容易出现丢帧。
该方案的优点是易于和编辑部分进行解耦,只需要将预览的Canvas进行抽帧导出即可。目前基于此抽帧方案+Pandam,在点播的视频编辑应用的基础上实现了简单的Demo:murloc.boe.goofy.app/。
seek抽帧
为了解决抽帧时机的问题,我们想到了通过调整视频currentTime来调整播放进度,这样可以进行准确的抽帧。而且从我们的直观感受上,对视频不断的快进,视频看得会更快,所需时间会更短。因为在seek之后还需要对视频进行解码,所以seek之后并不会立刻得到最新的帧,而是一个异步过程,我们可以监听seekend或者timeupdate来获取seek之后的视频帧。但是对一个完整视频抽帧后,发现所需时间是视频时长的2-3倍。这就需要分析一下浏览器视频的解码过程。
解码时,首先需要解码一个I帧,之后才能解码P帧。举个例子,如下图,我们如果要解码第2帧,需要先解码第1帧,然后再解码第二帧。如果此时seek到第9帧,不仅要解码第1帧和第9帧,还要顺序解码2-8帧,之后才是第9帧。
到这里我就有一个猜想,浏览器在处理seek时,会重置解码器,从seek点位的上一个I帧开始解码。也就是说我们从第8帧seek到第9帧,并不是再解码第9帧就可以,而是要重新解码1-9帧。所以总时长会增加。为了验证猜想,我们找一个20s的测试视频,第0s和10s是关键帧。分别seek到4.5s,5s,10.5s, 5s,分别耗时78ms, 79ms,20ms ,73ms。与猜想一致,离关键帧越近,耗时越少,从4.5s seek到 5s和直接seek到5s耗时差不多。
所以对整个视频进行抽帧,耗时远超过视频长度。知道了原因,我们可以更改抽帧方式,加快抽帧速度。
基于WebCodecs抽帧
既然直接调整currentTime不行,那就试一试其他seek方法,通过查阅文档,我找到了fastSeek和seekToNextFrame这两个方法,但都是非标准API,仅在火狐上实现了,Chrome都没有实现。那我们可以基于WebCodecs自己实现一个fastSeek或者seekToNextFrame方法。
先来看整体的流程图
为了能够seek到指定的时间,我们需要知道某一个时刻的视频帧是哪一帧,以及这一帧视频在mp4文件的什么位置。好在这些信息在mp4文件的封装信息中都已经存在了,但首先我们需要做的是解封装mp4视频,得到携带这些信息的box。在这里我利用的是xgplayer的解封装插件,也可以用mp4Box.js之类的库。然后通过分析stts,stco,stsz,stsc,stss等这些box,得到每一帧的时间、对应sample的偏移量、sample大小、关键帧位置。具体的视频数据在mdat中。而在H264编码格式下,解码视频所需的初始数据,都在avcC中。
接下来需要配置WebCodecs解码器。
this.decoder = new window.VideoDecoder({
output: this.handleDecode.bind(this),
error: this.handleError.bind(this),
});
this.decoderConfig = {
codec: getCodec(avcc),
description: avcc.data,
};
this.decoder.configure(this.decoderConfig);
之后从mdat中获取指定位置(this.currentDecodeIndex)的编码的视频信息,并封装为EncodecVideoChunk。
getNextChunk() {
const buffer = this.data.slice(
this.videoInfo.offset[this.currentDecodeIndex],
this.videoInfo.offset[this.currentDecodeIndex] +
this.videoInfo.size[this.currentDecodeIndex],
);
const time = this.videoInfo.timeline[this.currentDecodeIndex];
const isKeyFrame = this.videoInfo.keyframesIndex.includes(
this.currentDecodeIndex,
);
const chunk = new window.EncodedVideoChunk({
type: isKeyFrame ? 'key' : 'delta',
timestamp: time * 1000 * 1000,
data: new Uint8Array(buffer),
});
return chunk;
}
然后将chunk信息放入decoder中,解码过程是异步的,需要一个回调函数进行处理。我们先考虑简单情况,按顺序进行解码,不会拖动进度,这种情况下this.currentDecodeIndex可以理解为递增
const chunk = this.getNextChunk();
this.decoder.decode(chunk);
this.currentDecodeIndex++;
handleDecode(frame) {
this.cacheData.push(frame);
if (this.cacheData.length <= this.currentReadIndex) {
frame.close();
}
}
需要注意的是,不能缓存太多VideoFrame,非常占用内存,用过或者不再需要的VideoFrame要注意及时close。添加编码数据后,不会立即得到解码帧。需要在添加若干编码数据后,才会得到第一个编码帧。在视频结尾处,或者不再添加新的编码块后,需要调用flush,这样才能取到最后几帧数据。不过下次再启动解码时,需要从关键帧开始重新添加,不能从上次的结果继续。
解码器一边进行解码,编辑器一边进行消费,这是一个典型的生产者与消费的问题。为此维护一个cacheData的缓存队列,并且设置了缓存的最大长度,确保内存不会爆。
startDecode() {
// 缓存区未满
if (this.cacheData.length - this.currentReadIndex < this.config.maxCache) {
const chunk = this.getNextChunk();
this.decoder.decode(chunk);
this.currentDecodeIndex++;
}
// 未解码完毕
if (this.currentDecodeIndex < this.videoInfo.timeline.length) {
this.timer = setTimeout(this.startDecode.bind(this), 0);
} else { // 解码完毕
this.decoder.flush();
}
}
getNextFrame() {
return new Promise(resolve => {
this._check(resolve);
});
}
_check(resolve) {
// 要获取的帧已经被解码
if (this.cacheData.length > this.currentReadIndex) {
resolve(this.cacheData[this.currentReadIndex]);
this.currentReadIndex++;
} else {
setTimeout(() => {
this._check(resolve);
}, 0);
}
}
handleDecode(frame) {
this.cacheData.push(frame);
// 假如要从第10帧开始读取,那么0-9帧都是没用的数据,直接close
if (this.cacheData.length <= this.currentReadIndex) {
frame.close();
}
}
这样,我们在startDecode后,就可以不断的获取getNextFrame来得到下一帧。
在复杂场景下,我们需要通过seek调整视频播放进度,需要对浏览器默认的seek行为进行优化。
假设当前的解码与视频帧的消费情况如图所示。那么接下来的seek会出现4种情况。
- seek到粉色区域,这时候虽然这些帧已经被解码过了,但是解码后的视频帧已经被消费,不在缓存中了,如果要重新获取,则需要从第一个I帧开始重新解码;
- seek到黄色区域,这时候这些帧已经被解码过了,而且还未被消费,或者还未被解码完成,这时候可以在之前解码的基础上继续进行解码,仅仅调整读指针的位置,并清理不再使用的视频帧;
-
seek到绿色区域,这时候还未进行解码,但仍然在同一个GOP内,这时候可以在之前解码的基础上继续进行解码,仅仅调整读指针的位置,并清理缓存中不再使用的视频帧;
-
seek到红色区域,这时候还未进行解码,且已经进入到其他GOP中,为了加快抽帧速度,可以重置编码结果,从第2个I帧开始,重新进行解码;
具体代码如下
fastSeek(time) {
const { timeline, keyframes } = this.videoInfo;
let i = 0;
// 寻找目标时间对应的编码块与关键帧
for (; i < timeline.length - 1; i++) {
if (time >= timeline[i] && time < timeline[i + 1]) {
break;
}
}
let j = 0;
for (; j < keyframes.length - 1; j++) {
if (time >= keyframes[j].startTime && time < keyframes[j + 1].startTime) {
break;
}
}
// 如果是向后seek,且在同一个GOP内, 或者 向前seek,且还未读取
if (
(i >= this.currentDecodeIndex && this.currentDecodeIndex >= j) ||
(i < this.currentDecodeIndex &&
this.currentReadIndex + this.baseOffset <= i)
) {
// 把缓存的未读取的frame全部关闭,释放资源
for (let k = this.currentReadIndex; k < this.cacheData.length; k++) {
this.cacheData[k].close();
}
this.currentReadIndex = i;
} else {
// 其他情况:向前seek,且已读取;向后seek,且不在同一个GOP
this.reset();
this.decoder.configure(this.decoderConfig);
this.currentDecodeIndex = keyframes[j].index;
this.baseOffset = this.currentDecodeIndex;
this.currentReadIndex = i - this.currentDecodeIndex;
this.startDecode();
}
}
至此,我们实现了一个快速的抽帧方法,经测试,使用该方法对720P视频进行帧率为30帧的抽帧,所需时间仅需视频时长的1/4-1/3。相对于定时器方法,速度快了3-4倍,且抽帧的时机可以精准控制;相对于seek抽帧,速度快了6-9倍。对于清晰度更高的视频,所需时间会略微增加,但肯定会小于视频总时长。
该方案的缺点就是和编辑部分逻辑耦合,目前已知的几个视频编辑SDK都无法与此方案快速融合,都需要进行二次编辑。
视频编码和导出
在上一步当中,我们得到了VideoFrame,接下来,我们可以通过VideoFrame得到一个WebGLTexture,并对它进行一定的编辑(编辑步骤应于预览时一致,这样才能保证所见即所得),并渲染到一个新的canvas上。之后我们通过下面方法来获得一个新的VideoFrame。这一步骤可以理解为对上面cacheData队列的消费,也是导出步骤的开始。
const frame = new window.VideoFrame(canvasRef.current, {
timestamp: currentTime * 1000 * 1000,
});
接下来需要配置一个VideoEncoder,需要指定具体的编码格式,帧率,宽,高以及码率,其中码率是可选的,如果不指定,在1280*720、30帧下是2M,其他情况Chrome会根据帧率、宽、高按比例计算出一个码率。如果对导出后的清晰度或者文件大小不满意,可以适当增大或者减少这个值。需要注意的是,帧率最大为30,超过30会导致Encoder直接进行关闭状态。
this.encoder = new window.VideoEncoder({
output: this.handleOutput.bind(this),
error: this.handleError.bind(this),
});
const DefaultVideoConfigs = {
codec: 'avc1.640828',
framerate: 30,
};
this.videoConfigs = {
...DefaultVideoConfigs,
// bitrate,
displayWidth: videoConfigs.width,
displayHeight: videoConfigs.height,
};
this.encoder.configure(encoderConfig);
之后将我们上面得到的VideoFrame放入编码器即可
addFrame(frame) {
if (this.pendingOutputs > this.config.queueLength) {
return false;
}
this.pendingOutputs++;
this.frameCount++;
const isKeyFrame = this.frameCount % this.config.gop === 10;
this.encoder.encode(frame, { keyFrame: isKeyFrame });
frame.close();
return true;
}
keyFrame参数决定编码结果是否为关键帧,实测在部分场景下这个并不能完全决定编码结果是否为关键帧。如果为true,则结果一定是关键帧,如果为false,在AnnexB编码格式下,结果可能是关键帧,也可能不是。
有文章说encode方法会消耗VideoFrame,所以不需要close,但实测还是需要手动close,否则会有一个warning。
之后我们在output的回调函数中就可以拿到编码结果
handleOutput(chunk, config) {
this.pendingOutputs--;
this.frameChunks.push(chunk);
if (!this.avcc && config.decoderConfig?.description) {
this.avcc = config.decoderConfig.description;
}
}
上文中,我们解码时需要一个avcC box,在封装时,我们也会需要一个avcc的信息。在Annex-B格式中,它在视频的第一帧,在AVCC格式中,它在第一次回调的config参数中。在导出为文件时,我们一般用AVCC格式编码。在配置编码器时,我们可以指定编码的格式
{
codec: 'avc1.640828',
width: videoRef.current.videoWidth,
height: videoRef.current.videoHeight,
displayWidth: videoRef.current.videoWidth,
displayHeight: videoRef.current.videoHeight,
bitrate: 800_000_000,
framerate: 30,
// avc: {
// format: 'annexb', // 参考https://github.com/mattdesl/mp4-h264/blob/main/test/webcodecs.html#L112
// },
}
在所有的视频帧都编码完毕后,我们就需要对编码后的数据进行封装了,本文将其封装为最常用的视频格式——mp4。在这里我是利用xgplayer的封装器,网上也有一些基于c编译的wasm的库可以使用。如果有兴趣,可以自己造个轮子。在封装时,我们需要一些meta信息,如编码方式,宽高,每个chunk的位置,parRatio,avcc,dts,pts等。部分信息可以通过用户配置或者之前编码后的数据得到。部分数据可以写死,如parRatio可以固定为1:1。在没有B帧的情况下,dts和pts是一样的,直接用视频帧的时间戳做参数即可。avcc在上文中已得到。
至此,我们成功将图像编码为一个视频文件。但是还缺少音频部分。
音频编辑和导出
音频的编辑相对简单,我们可以利用AudioContext来实现。所以,我们只需要将音频素材导入到AudioContext中,经过一系列的处理节点,然后从AudioContext中导出,再编码,封装即可。
为了保证导出时和编辑时的音效相同,我建议使用相同的AudioNode网络,仅仅替换AudioContext为OfflineAudioContext,前者是将编辑后的音频导出到扬声器,而后者是将编辑后的音频导出到内存,而且不受时间轴的限制,速度非常的快。对于那些处理效果和运行时间绑定的case,我们可以用AudioWorkletNode来实现。
在解码部分,AudioContext提供了解封装和解码的能力,我们不需要像视频部分一样需要去解析文件格式。
const audioContext = new AudioContext();
// 从音频文件得到AudioBuffer
audioContext.decodeAudioData(fileBuffer, async (audioBuffer) => {
// ...
}
之后我们拿到的AudioBuffer就是一个PCM的数据,包含音频采样率、声道数等信息。我们可以利用这些Buffer数据构建一个AudioBufferSourceNode,作为一个声源。
const offlineAudioContext = new OfflineAudioContext(audioBuffer.numberOfChannels, audioBuffer.length, audioBuffer.sampleRate)
const source = offlineAudioContext.createBufferSource();
source.buffer = audioBuffer;
在得到SourceNode后,对其进行音频的编辑。具体编辑能力可以参考Pandam的实现,有环绕音,渐弱渐强等。这里我们以AudioWorkletNode实现音量渐强为例,这里不直接使用GainNode,是因为用GainNode实现音量渐变需要与时间进行关联,而在offlineAudioContext下,处理时间和播放的时间并不对应。
// Gain.worker.js
class GainNode extends AudioWorkletProcessor {
constructor() {
super();
this.gain = 0;
}
process(inputs, outputs) {
inputs[0].forEach((item, index) => {
outputs[0][index].set(item.map(data => data * this.gain));
});
if (this.gain < 1) {
this.gain += 0.001;
}
return true;
}
}
registerProcessor('gain-node', GainNode);
// 增加处理节点
await audioContext.audioWorklet.addModule('/Gain.worker.js');
const gain = new AudioWorkletNode(audioContext, 'gain-node');
sourceNode.connect(gain);
gain.connect(offlineAudioContext.destination);
之后将SourceNode进行播放,等待处理完毕,即可得到编辑后的AudioBuffer。
source.start();
const processedBuffer = await offlineAudioContext.startRendering();
然后就需要通过AudioEncoder对编辑后的AudioBuffer进行编码。首先是配置AudioEncoder,Chrome的WebCodecs目前还不支持AAC格式的音频编码,我们只能退而求其次,用Opus的编码格式。h264+opus的封装确实不太常见,但在浏览器中是可以播放的。不过在Mac的 QuickTime中无法播放。在确定Opus的编码格式后,采样率也就确定了,只能是48000。声道数和音频源以及编辑结果有关,一般就是双声道。
const DefaultAudioConfigs = {
codec: 'opus',
sampleRate: 48000,
};
this.audioConfigs = {
...DefaultAudioConfigs,
...audioConfigs,
};
this.audioEncoder.configure(this.audioConfigs);
之后需要将AudioBuffer封装为AuidoData,然后放入AudioEncoder。封装过程中,format字段指的是PCM数据的精度以及不同声道数据的位置。不同声道的数据可以相互交叉,或者读取完一个声道后再读取另一个声道。这里采用后者。为此,我们需要重新排列下AudioBuffer的ChannelData数据。
const data = new Float32Array(processedBuffer.length * processedBuffer.numberOfChannels);
for(let i = 0;i < processedBuffer.numberOfChannels;i++) {
data.set(processedBuffer.getChannelData(i), i * processedBuffer.length)
}
new AuidoData({
format: 'f32-planar',
sampleRate: processedBuffer.sampleRate,
numberOfFrames: processedBuffer.length,
numberOfChannels: processedBuffer.numberOfChannels,
timestamp: Date.now(),
data,
})
之后将AuidoData放入AudioEncoder中,即可得到编码后的数据。最后利用xgplayer的封装器进行封装。因为Opus并不是最常用的编码格式,xgplayer的开源版本中,没有实现Opus的封装,参考opus-codec.org/docs/opus_i…
同其他导出方案的对比
Ffmpeg
将ffmpeg编译为WebAssembly,在浏览器直接使用。ffmpeg的封装非常好,使用起来非常简单,如果是直接将音视频文件进行截断、拼接、简单的滤镜,那么我还是首推ffmpeg的,使用简单、速度也比较快。但是本次导出是为了应用于纯前端的视频编辑场景,ffmpeg不易将中间产物进行预览。如果将预览和导出的编辑分开,又不能确保所见即所得。当然,ffmpeg提供了强大的扩展能力,也可以实现本文中的fastSeek或者seekToNextFrame的能力,但我短期内不具备扩展ffmpeg功能的能力。目前竞品clipchamp有类似方案,但未开源相关代码。整体来说方案可行,但有一定的门槛,需要对ffmpeg有深入了解。而且在速度上,此方案不具备优势,但在各种封装格式和编码格式的兼容性上,具备非常大的优势。
MediaRecorder
MediaRecorder可以将MediaStream进行录制,并导出为文件。相对于WebCodecs,它的兼容性更好。而且MediaRecorder是直接导出为视频文件,不需要先编码、再封装,一步到位。略显不足的地方就是导出的文件格式受限,Chrome只能导出为webm;编码参数细节无法控制;音视频需要同步传入。
上述问题对我们的影响都不大。致命的问题在于视频导出的帧率,我们从canvas上获取MediaStram一般是通过camptureStream,所以得到的视频流的帧率就是我们在canvas上的绘制频率,一旦导出时出现卡顿,那么导出的视频必然也出现卡顿。这里也尝试过用MediaStreamTrackGenerator的方式,试图通过控制VideoFrame的时间戳,修正页面卡顿导致的视频卡顿,但实际上视频帧出现的时机只和写入时机有关,和VideoFrame的时间戳无关。也考虑过使用VideoFrame的缓存池来抗卡顿,但是大幅增加了内存占用,还需要考虑音视频对齐的问题,编码复杂度并不低于WebCodecs的方案。在音频上,音频处理可以单开一个线程,就不易出现卡顿问题,而且相对WebCodecs,还可以支持AAC的编码格式。
所以如果是纯音频导出,可能比WebCodecs会更好;如果是视频导出,如果视频源本身就是MediaStream(比如摄像头或者RTC远端流),优先使用MediaRecorder会更好,或者有方案保证你的canvas绘制时不会卡顿,那么从canvas导出也是可以的。
另外,此方案的导出时长为视频播放时长,不具备速度优势。
优缺点
优点
- 不占用服务端资源,成本低,易做免费推广,为其他应用引流
- 相对于竞品,导出速度快,与worker结合后,速度可以更快
缺点
- 在编辑长视频时,会导致内存占用过高,所以仅适用于中短视频的编辑和导出
- 导出过程中不能关闭浏览器,此问题其他前端导出方案也存在
- WebCodecs支持的编码方式不够多,只能等待浏览器更新能力
- 针对其他封装格式,如mov,ts,flv文件的视频,需另行编写解封装方法