Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

基于 Webcodec 实现视频播放器 #39

Open
shaozj opened this issue Oct 30, 2022 · 0 comments
Open

基于 Webcodec 实现视频播放器 #39

shaozj opened this issue Oct 30, 2022 · 0 comments

Comments

@shaozj
Copy link
Owner

shaozj commented Oct 30, 2022

近些年来,web 浏览器对多媒体处理的支持不断增强。chrome 94 开始支持了 Webcodecs API,这使得我们能深入到视频帧这一层对视频进行处理。在这之前,如果想要处理这种基础的视频元素,我们需要借助 WebAssembly 将视频编解码器引入到浏览器中。然而,实际上浏览器的底层早就集成了大量的编解码器,使用 wasm 方案一方面多引入了不必要的资源,另一方面在性能上也很难达到实际需求。
MMPlayer 是阿里妈妈前端技术部创意团队开发的用于播放创意中心剧本数据结构的播放器。在创意中心剧本数据结构中,可以包含视频、图片、lottie 数据以及音频等等。MMPlayer 在播放视频元素时,基于 video 标签,根据计算出的播放时间控制 video 标签中视频的播放,将对应时刻的视频帧渲染到 canvas 中,并和其他需要同时渲染的元素一起渲染到最终页面展示的 canvas 中。通过设置 video 标签的 currentTime 来 seek 某一时刻的视频帧。基于 MMPlayer 和创意剧本数据结构,我们开发了混剪工具,可以实现智能剪辑视频的功能。然而在 web 端剪辑视频时,我们遇到一个问题,在 chrome 浏览器端预览的视频片段和经服务端生成得到的视频片段,片段的头尾存在1到2帧的差异。也就是说,在浏览器端看到的剪辑效果和最终服务端生成视频的视频效果,存在差异。这就导致了剪辑出的视频存在夹帧(出现几帧不该出现在该片段的帧,导致画面不连续)的问题。

image

经过分析,我们发现,在 chrome 上,通过 currentTime 获取到视频帧,和实际视频流中对应时刻的视频帧是有差异的。在这个 chrome bug 反馈中可以看到,为了获得更平滑的播放效果,chrome 中的 video 标签没有让 currentTime 渲染的帧和实际视频帧保持一致。而在 chrome 看来,更平滑的播放效果是他们更在意的用户场景,所以并没有修复也不打算修复 currentTime 渲染视频帧时刻不准确的问题。
为了解决上述问题,我们通过基于 Webcodec VideoDecoder API 来自己实现视频播放器,从而能够根据剪辑时间点精确获取我们需要的视频帧,供预览和渲染,解决 web 端视频剪辑时的夹帧问题。

总体方案

实际上,我们需要实现的视频播放器不需要音频解码功能(因为音频部分 mmplayer 中自己处理了),同时,我们也无需对照 web video 元素实现其所有功能,我们只需根据 mmplayer 中的需要实现视频播放器的基础功能。主要包括 initialize、play、pause、seek 以及 dispose,分别对应初始化、播放、暂停、搜索定位以及释放播放器的功能。在技术实现上,整个播放器的流程图如下:
image
整个 webcodec 视频播放器(以下简称 wcVideoPlayer)被放置在一个 web worker 中,和 MMPlayer 通过 postmessage 通信。在初始化 wcVideoPlayer 阶段,首先通过 fetch 获取视频流,通过 response.body.getReader()流式读取视频流并给到 demuxer 进行解封装获取到视频 Sample。Demuxer 基于 mp4box.js 实现,这里有一个 Sample Buffer,解出来的 video sample 将存入 Sample Buffer,当 Sample Buffer 满了后,会停止解封装。Sample 被消耗后 Sample Buffer 空出来会继续解封装。这个 Buffer 使得我们能控制一次解封装出来的 sample 数量,使得我们能尽快地开始解码视频,以及控制 sample 所占内存。Decoding 是我们的核心步骤,在 Decoding 时,我们读取 demuxer 解析出来的 samples,一个 sample 对应了一帧视频。这里有两个关键的 buffer,一个是 VideoDecoder 自带的 Decode Queue,这个队列中存储了待解码的帧。另一个是 Frame Buffer,这是我们实现的解码出的帧的 buffer。这两个 buffer 是我们控制解码消耗内存的关键,将在后续分析。有了解码出的视频帧后,下一步就是渲染。 你可能会好奇为什么要把 wcVideoPlayer 放在 web worker 中。原因是主线程在工作时如果非常繁忙,例如页面中存在着很多动画(特别基于 react 的场景),那么主线程中的其他工作,页面的渲染,以及 wcVideoPlayer 中的解码操作,就会互相 block,导致解码被卡住。由于对内存控制的需要,解码过程是边播边解的,播放完的视频帧也要及时释放掉。一旦主线程卡住,解码过程也就被卡住,视频将无法正常播放。特别是视频的解码依赖于 requestAnimationFrame 来循环,主线程中的 react 动画和频繁 re-render 非常容易导致 requestAnimationFrame 循环的时间间隔很长,导致视频无法及时解码。所以,必须把视频的解码工作放到 web worker 中,这样不会和主线程相互 block。但这里有个问题,如果把解码出的 VideoFrame 给回到主线程渲染?这里就要用到 OffscreenCanvas 技术。使用 transferControlToOffscreen 方法将主线程 canvas 的控制转移到 web worker 的 OffscreenCanvas 对象上。在 OffscreenCanvas 上的操作将自动运用到主线程的 canvas 上。我们将 VideoFrame 绘制到 OffscreenCanvas 即可。

this.videoWorkerCanvas = document.createElement('canvas');
this.videoWorkerCanvas.width = this.config.width;
this.videoWorkerCanvas.height = this.config.height;
const offscreenCanvas = this.videoWorkerCanvas.transferControlToOffscreen();
this.videoWorker = new VideoWorker();
this.videoWorker.postMessage({
  command: 'initialize',
  src: this.url,
  stopTime: this.outFrame / this.fps,
  fps: this.fps,
  canvas: offscreenCanvas
}, {
  transfer: [offscreenCanvas]
});

至此,一个视频获取、demux、decode、render 的完整流程就有了。同时我们还要支持 play,pause,seek 等功能,将在后面逐一介绍。

播放控制和内存管理

前面我们提到,Frame Buffer 和 Decode Queue 是视频解码播放时控制内存的关键所在。为什么我们不直接解码缓存所有视频帧,在播放时取出对应时刻的视频帧渲染。在 seek 时取到对应时刻的一帧视频即可。这个在实现上将变得非常简单。然而这个方案有个致命的问题,就是将占用巨大的内存,页面将由于内存不足而直接崩溃。解码得到的每帧视频所占用的内存是巨大的,假设一个 RGB 像素需要 24bit,那么一帧 1080P 的视频将占用将近 6MB的内存。一个5分钟 25 fps 的视频,内存占用将达到 27GB。为了解决内存问题,我们需要引入 Frame Buffer 来控制当前缓存的视频帧的个数,我们将该个数设置为 3。
现在,整个播放控制的逻辑就复杂起来了。首先,我们基于 requestAnimationFrame 循环来获取对应时刻的视频帧,包括解码和获取两步。

play() {
  this.playing = true;
  this.lastMediaTimeCapturePoint = performance.now();
  const renderVideo = () => {
    if (!this.playing) {
      return;
    }
    const currentTime = this.getMediaTimeMicroSeconds() / this.fps;
    if (currentTime >= this.stopTime * 1000000) {
      this.pause();
    }
    this.videoRenderer.render(currentTime);
    requestAnimationFrame(renderVideo);
  };
  requestAnimationFrame(renderVideo);
}

在解码时,需要从 frameBuffer 中获取到时间戳最接近当前时刻的帧,当前时刻之前的帧都丢弃,释放内存,让frameBuffer 空出来。这就需要我们不断更新 frameBuffer,使得里面始终只保留当前时刻的帧以及之后的两帧。

async fillFrameBuffer(seekId) {
  ...
  if (this.frameBufferFull()) {
    if (this.init_resolver) {
      this.init_resolver();
      this.init_resolver = null;
    }

    return;
  }

  if (this.fillInProgress) {
    return false;
  }
  this.fillInProgress = true;

  while (this.frameBuffer.length < FRAME_BUFFER_TARGET_SIZE && 
         this.decoder.decodeQueueSize < FRAME_BUFFER_TARGET_SIZE) {
    const sample = await this.demuxer.readSample();
    this.decoder.decode(this.makeChunk(sample));
    // read last sample/frame,need to flush decoder
    if (Math.round(sample.dts / sample.timescale * this.fps) + 1 === this.trackInfo.nbSamples) {
      this.decoder.flush();
      this.finishReadSample = true;
      break;
    }
  }

  this.fillInProgress = false;

  setTimeout(() => this.fillFrameBuffer(seekId), 0);
}

因为 frameBuffer 在不断更新之中,我们需要循环 fillFrameBuffer 以保证解码过程中 frameBuffer 始终被填满。基于这个缓存控制机制,我们将解码播放时消耗的内存始终控制在 3 帧视频。除了在解码时的内存控制,在销毁播放器时,也需要做清理视频帧以及 close decoder 等清理内存的工作:

dispose() {
  this.disposing = true;
  for (const frame of this.frameBuffer) {
    frame.close();
  }
  this.frameBuffer = [];
  if (this.decoder.state !== 'closed') {
    this.decoder.close();
  }
  releaseCanvas(this.canvas);
  this.demuxer.dispose();
}

在验证过程中,我们发现始终无法完全清理内存,存在着内存泄露。在写了更为简洁的复现 demo 后我们怀疑是 chrome 自身实现存在问题。我们将 bug 提交给 chromium 反馈论坛,目前他们已经确认了 chrome 中 VideoDecoder 的实现存在内存泄露,并在处理中。

seek 的实现和优化

视频 seek 的实现相对于播放来说更为复杂。其主流程如下:

  1. demuxer seek 到时间点 T 所在的或在其之前的那帧关键帧
  2. reset() decoder,然后从那帧关键帧开始解码,一直解码到时间点 T 的那帧
  3. decode 过程中释放掉所有 T 时刻前的帧
  4. 暂停或者继续播放

这里涉及到视频编码相关的知识点。在编码后的视频流中,只有关键帧是可以独立解码的,其他帧的解码都依赖于之前解码的帧,所以第一步必须找到关键帧。所幸 MP4box 已经实现了 seek 方法,使得我们后续解码只需从 read demuxer seek 到的 key frame sample 开始即可。为了能从关键帧一直解码到 T 时刻的帧,我们需要换一种方式控制 frameBuffer,先将 frameBuffer 清空,然后一直解码,编解码边 close 不是我们需要的帧,知道获取到 T 时刻的帧,将其存入 frameBuffer,之后获取 frameBuffer 中的帧并渲染。
上述方案每次 seek 都需要清空 frameBuffer 并从最近时刻的关键帧从头开始解码。在一些场景下,我们并不需要这么做。当前已经解码的帧和当前的解码器我们是可以继续利用的。如果当前帧比最近关键帧距离时刻 T 更近,那么更好的做法是继续当前解码器的解码,直到解码到时刻 T 的那一帧,而不是从关键帧重新开始解码。

const keyFrameTime = this.demuxer.seekKeyFrameTime(seekTime);
  if (currentTime >= keyFrameTime && currentTime <= seekTime) {
    const candidateFrame = this.chooseFrame(seekTime * 1000000);
    if (candidateFrame && this.frameBuffer.length > 1) {
      return candidateFrame;
    } else {
      // continue decode until get target frame
      this.seeking = true;
      this.seekTime = seekTime;
      await this.fillFrameBuffer(seekId);
      await new Promise((resolve) => (this.seekResolve = resolve));
      return this.render(seekTime * 1000000);
    }
  }

这个方案很大地优化了从当前时刻向后 seek 时的性能。特别是当我们尽心单步向后 seek 视频时,其效果远远好于基于 video 标签的单步 seek,画面预览非常流畅。
经常会有用户短时间内不断 seek 的操作,如果每次 seek 来我们都执行完整的解码操作,其对性能会带来很大的消耗,而这些消耗是不必要的。如果在用户一次 seek 时还没能渲染出画面就来了下次 seek,此时应该停止掉上次 seek 的继续执行,而为下次 seek 来进行解码。这样能帮我们优化性能和节省内存。

async seek(seekTime, currentTime) {
  ...
  const seekId = ++this.seekId;

  if (this.seeking && this.seekResolve) {
    // 提前结束上一个 seek,此时没有帧供渲染,要注意
    this.seekResolve();
    this.seekResolve = null;
  }
  ...
}

async fillFrameBuffer(seekId) {
  // 防止之前的 seek 继续触发 fillFrameBuffer 操作
  if (seekId < this.seekId) {
    return;
  }
   ...
}

在 seek 时,也需要特别注意内存的释放。除了 frameBuffer 中视频帧的释放。还要注意,在 seek 时,我们直接将 seek 到的 video frame 传回了主线程,这是因为如果依赖 offscreenCanvas 的渲染来同步画面,由于存在时延问题,会导致主线程中渲染了上一个时间点的帧而不是最新的 offscreenCanvas 中的画面。对于传回了主线程的 video frame,要在主线程中及时释放。

一些需要注意的点

除了上面说到的,其实在开发中还遇到了各种大大小小的问题,为了避免后人踩坑,这里也记录一下。
在 chrome 中同时发送多个 url 相同的视频请求,后面几个请求经常会被卡住。而我们剪辑视频的功能,在拼接统一视频的多个片段时就是需要发送多个相同 url 请求的。这导致加载播放器会变得特别的慢。为了解决该问题,我们开发了 cacheFetch 功能,让同一时刻只有一个请求,且缓存下来,这样大大加速了获取视频的速度。
在切换 chrome tab 后,之前 tab 的 requestAnimationFrame 将不会继续执行。如果我们的视频正在播放中,那么切换 tab 后会导致解码停止。为了解决该问题,在监听到 visibilitychange 时,我们将视频解码的循环切换到 settimeout 的方式,同时此时不渲染视频,只解码视频。当页面重新可见时,又切换回 requestAnimationFrame,此时只需去除对应时刻帧渲染,并继续解码即可。
一开始发现 seek 时间点不准,是因为我们计算视频 timestamp 时直接用 cts 导致,其实 cts 不是从 0 开始的,我们用 mediatime 对 cts 进行校正以获取到准确的 timestamp。

const pts_us = (sample.cts - this.trackInfo.mediaTime) * 1000000 / sample.timescale;

在配置 VideoDecoder 参数时,不要开启 optimizeForLatency,这样解码更慢。
解码时最后 4 帧没有解码出来,当解码到视频最后时,需要调用 flush。需要判断什么时候 sample read 完了,然后 flush,并禁止继续循环 fillFrameBuffer:

while (this.frameBuffer.length < FRAME_BUFFER_TARGET_SIZE && this.decoder.decodeQueueSize < FRAME_BUFFER_TARGET_SIZE) {
    const sample = await this.demuxer.readSample();
    this.decoder.decode(this.makeChunk(sample));
    // read last sample/frame,need to flush decoder
    if (Math.round(sample.dts / sample.timescale * this.fps) + 1 === this.trackInfo.nbSamples) {
      this.decoder.flush();
      this.finishReadSample = true;
      break;
    }
  }

总结

本文介绍了我们在视频业务开发时为了解决 web 端视频剪辑的夹帧问题,而开发了基于 webcodec 的视频播放器的技术方案和实现。实现了视频播放器基础的 init、play、pause、seek 和 dispose 等功能。根据业务需要,我们只实现了视频的相关功能,其实基于 webcodec,我们也能实现音频相关的功能,并将两者整合为一个完整的播放器。目前该播放器已经集成进我们的 MMPlayer 中,并尝试在混剪业务中应用。然而,由于 VideoDecoder 的内存泄露问题,导致我们现在还无法在线上开启该功能。所幸在向 chrome 团队反馈该 bug 后,他们已经在解决中,相信在不久的将来,我们的 webcodec video player 将可以正式上线。
目前,wcVideoPlayer 相对于 video 标签,其解码速度还略慢,平均一帧 5.8ms 对比 video 标签 3ms。在播放时没有影响,在 seek 时就会略慢。在 cpu 和内存占用上相比 video 也略高。后续需要我们持续关注 chrome 团队在这方面的优化,我们也会编写更多的一些示例来向 chrome 反馈相关的问题。

参考资料

https://github.com/w3c/webcodecs
https://web.dev/webcodecs/
https://developer.chrome.com/blog/offscreen-canvas/
chrome 同一个 url 多次请求问题 https://blog.csdn.net/Dongguabai/article/details/83240189
https://bugs.chromium.org/p/chromium/issues/detail?id=555376
https://bugs.chromium.org/p/chromium/issues/detail?id=1376851

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant