WebCodecs视频导出实践

avatar
FE @字节跳动

背景

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

架构分析

视频由一帧帧的图片组成,对视频的编辑实际上就是对每一帧图片的编辑。我们可以粗略的将视频编辑的步骤按下图进行划分。

image.png 视频帧本质上就是一张图片。从素材得到视频帧,就是一个将素材转化为图片的过程,素材可能是一张图片、一段文字或者一个视频。其中最复杂的情况就是由视频得到图片,也就是视频抽帧。

从视频帧得到编辑后的视频帧是编辑的步骤,可以用WebGL去实现,比如Pandam,也可以用WebAssembly实现,如VESDK,本文不去展开讨论。

将编辑后的视频帧渲染在页面canvas上,就是预览,这个步骤相对简单,本文也不去展开。

将编辑后的视频帧导出为视频文件就是导出,从一帧帧的图片得到一个可播放的视频,这个步骤又可以被细分为编码和封装。

当然一个视频的处理还包含音频部分,不过相对于视频,其会简单很多。

下面将着重介绍视频图像抽帧、图像导出、音频导出。细分后的框架如下

image.png

抽帧

定时器抽帧

在预览场景下,我们最常用的抽帧方案就是定时器,用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帧。

image.png

到这里我就有一个猜想,浏览器在处理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方法。

先来看整体的流程图

image.png 为了能够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的缓存队列,并且设置了缓存的最大长度,确保内存不会爆。

image.png

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种情况。

image.png

  1. seek到粉色区域,这时候虽然这些帧已经被解码过了,但是解码后的视频帧已经被消费,不在缓存中了,如果要重新获取,则需要从第一个I帧开始重新解码;
  1. seek到黄色区域,这时候这些帧已经被解码过了,而且还未被消费,或者还未被解码完成,这时候可以在之前解码的基础上继续进行解码,仅仅调整读指针的位置,并清理不再使用的视频帧;
  1. seek到绿色区域,这时候还未进行解码,但仍然在同一个GOP内,这时候可以在之前解码的基础上继续进行解码,仅仅调整读指针的位置,并清理缓存中不再使用的视频帧;

  2. 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导出也是可以的。

另外,此方案的导出时长为视频播放时长,不具备速度优势。

优缺点

优点

  1. 不占用服务端资源,成本低,易做免费推广,为其他应用引流
  1. 相对于竞品,导出速度快,与worker结合后,速度可以更快

缺点

  1. 在编辑长视频时,会导致内存占用过高,所以仅适用于中短视频的编辑和导出
  1. 导出过程中不能关闭浏览器,此问题其他前端导出方案也存在
  1. WebCodecs支持的编码方式不够多,只能等待浏览器更新能力
  1. 针对其他封装格式,如mov,ts,flv文件的视频,需另行编写解封装方法