关于 WebCodecs API 的一些问题

WebCodecs API 是一系列用来编辑音视频的浏览器能力,最近团队在优化视频播放器的性能,发现 这些 API 的能力很强大!

WebCodecs API 可以用来做什么?

WebCodecs API 有很多个子 API 组成,使用这些 API 我们可以:

  1. 解码一个音视频内容,得到原始关键帧。
  2. 对关键帧进行逐帧的编辑,例如自定义的变化,滤镜,特效等。
  3. 可以直接使用 canvas 来播放,也可以再次使用 WebCodecs API 进行编码,压缩体积便于传输。

所有这些操作都非常的高效,高性能。

总的来说,WebCodecs API 非常适合精细的对音视频进行编辑,例如视频编辑器,串流平台,自定义视频播放器等。

软解码还是硬解码?

历史上由于 H.265 视频兼容性问题,我们可能会尝试使用一些软解码方案,通过计算得到视频的每一帧,发给 canvas 进行渲染。而使用 WebCodecs API 容易给人一种在做软解码的感觉,但其实 WebCodecs API 是浏览器 native 能力,依据不同的浏览器实现,通常会尽可能使用硬件加速,也就是使用硬件来进行解码,效率要高很多。

如何使用呢?

我们可以通过一个例子来快速了解 WebCodecs API,w3c 提供了 WebCodecs API 的示例代码,它使用 WebCodecs API 解码了一个 H.265 视频(MP4 封装),并使用 canvas 来播放: https://github.com/w3c/webcodecs/tree/main/samples/video-decode-display

1
2
3
4
5
6
7
8
9
function start() {
const videoCodec = document.querySelector("input[name=\"video_codec\"]:checked").value;
const dataUri = `../data/bbb_video_${videoCodec}_frag.mp4`;
const rendererName = document.querySelector("input[name=\"renderer\"]:checked").value;
const canvas = document.querySelector("canvas").transferControlToOffscreen();
const worker = new Worker("./worker.js");
worker.addEventListener("message", setStatus);
worker.postMessage({dataUri, rendererName, canvas}, [canvas]);
}

可以看到主要的播放代码放在了 worker.js 中,创建了一个 canvas 移入了 worker 中进行渲染。这也是 WebCodecs API 的一个很大优势,可以将解码过程放在 worker 中,最大限度减少主线程的卡顿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
importScripts("demuxer_mp4.js", "renderer_2d.js", "renderer_webgl.js", "renderer_webgpu.js");

// Status UI. Messages are batched per animation frame.
let pendingStatus = null;

function setStatus(type, message) {
if (pendingStatus) {
pendingStatus[type] = message;
} else {
pendingStatus = {[type]: message};
self.requestAnimationFrame(statusAnimationFrame);
}
}

function statusAnimationFrame() {
self.postMessage(pendingStatus);
pendingStatus = null;
}

// Rendering. Drawing is limited to once per animation frame.
let renderer = null;
let pendingFrame = null;
let startTime = null;
let frameCount = 0;

function renderFrame(frame) {
if (!pendingFrame) {
// Schedule rendering in the next animation frame.
requestAnimationFrame(renderAnimationFrame);
} else {
// Close the current pending frame before replacing it.
pendingFrame.close();
}
// Set or replace the pending frame.
pendingFrame = frame;
}

function renderAnimationFrame() {
renderer.draw(pendingFrame);
pendingFrame = null;
}

// Startup.
function start({dataUri, rendererName, canvas}) {
// Pick a renderer to use.
switch (rendererName) {
case "2d":
renderer = new Canvas2DRenderer(canvas);
break;
case "webgl":
renderer = new WebGLRenderer(rendererName, canvas);
break;
case "webgl2":
renderer = new WebGLRenderer(rendererName, canvas);
break;
case "webgpu":
renderer = new WebGPURenderer(canvas);
break;
}

// Set up a VideoDecoder.
const decoder = new VideoDecoder({
output(frame) {
// Update statistics.
if (startTime == null) {
startTime = performance.now();
} else {
const elapsed = (performance.now() - startTime) / 1000;
const fps = ++frameCount / elapsed;
setStatus("render", `${fps.toFixed(0)} fps`);
}

// Schedule the frame to be rendered.
renderFrame(frame);
},
error(e) {
setStatus("decode", e);
}
});

// Fetch and demux the media data.
const demuxer = new MP4Demuxer(dataUri, {
onConfig(config) {
setStatus("decode", `${config.codec} @ ${config.codedWidth}x${config.codedHeight}`);
decoder.configure(config);
},
onChunk(chunk) {
decoder.decode(chunk);
},
setStatus
});
}

// Listen for the start request.
self.addEventListener("message", message => start(message.data), {once: true});

worker.js 中的代码也不算多,做的事情也比较简单:

  1. 使用 demuxer_mp4.js 对 MP4 文件进行解析。

    在视频解码的流程中,“demuxing”(复用器解封装,也叫解复用)是指从一个包含多种数据流(比如视频、音频、字幕等)的媒体容器文件(比如 MP4 文件)中,将这些不同类型的流分离出来的过程。这里仅仅是分离各路数据的过程,不会直接对音视频本身的编码格式做变换或解码。

  2. 创建了一个 VideoDecoder 实例,demuxer 得到配置和 chunk 传给 VideoDecoder 来进行处理。
  3. 在 VideoDecoder 的 output 回调中,我们可以逐帧处理视频,示例中将它交给 renderFrame 渲染在 canvas 中。

这么复杂,跟直接用 video 标签播放视频有什么区别?

对于大多数标准 MP4 文件播放场景,直接使用 <video> 标签就能获得高效、流畅的播放体验,因为浏览器已经为主流格式做了底层优化解码,主线程不会被显著占用,不需要额外引入 WebCodecs API。

WebCodecs API 的最大优势在于:

  1. 播放类似 FLV、Annex B H264、裸流、MPEG-TS 等 <video> 标签和 MSE 不直接支持的自定义编码视频流;
  2. 或者实现如涂鸦、AI处理、视频转码、自定义后期处理等高级自定义场景,为开发者提供了主线程之外的灵活解码和渲染能力

WebCodecs API 列表都有哪些?

主要的 WebCodecs API 类:

  1. VideoDecoder, AudioDecoder:对压缩后的音频流进行解码得到原始音频帧。
  2. VideoEncoder, AudioEncoder: 将原始视频帧编码为压缩后的视频流。
  3. VideoFrame:用来表示解码后得到的视频帧,可以修改或者进行渲染。
  4. AudioData:用来表示解码后得到的音频块,同样可以修改或播放。
  5. ImageDecoder:WebCodecs API 还支持对图片进行解码,得到图像的 bitmaps。

为什么 WebCodecs API 可以用来优化 flv 播放性能?

回答这个问题,我们需要先了解一下 flv 是如何播放的?浏览器默认是不支持 flv 播放的,但它本质上类似 mp4 也是一种封装格式,最大特点是支持流式传输,实现边下载边播放,延迟较低,因此尽管已经存在较久,直播场景还是在大量使用。

本质上 flv 也是对视频流和音频流的封装,如果我们将其 demuxing 拿到视频流和音频流,就可以进行播放了。开源库 flv.js 可以帮助我们方便的进行 flv 视频的播放,它的大致原理是:

1
flv 数据流 -> demuxing -> 封装为 MSE 可读格式 -> video 标签(MSE) -> 用户可观看

这里的 demuxing 是一个纯 JS 过程,而后续的播放则是利用了 MediaSource API 将这些打包好的数据动态写入 video 元素关联的 SourceBuffer 里,实现“边下边播”的播放效果。

那么问题来了,使用 WebCodecs API 优势在哪里呢?下图可以方便的对比传统方式与 WebCodecs API 播放视频的区别:

image.png

可以看到使用 WebCodecs API 可以使得整个编码与播放过程都发生在 worker 线程中,完全不打扰主线程的操作。

播放 flv 视频,主线程只是一个解包过程,性能有那么差吗?

通常来说 flv 的解包过程并没有那么大的计算量,但当进行高码率、大分辨率视频或者是多个视频同时播放的情况时,就会出现卡顿。

如果主线程有其他正在大量计算的内容,问题就会更加严重了。在我们的业务中遇到的情况是同时播放了十几个视频,每个视频上还需要通过 canvas 等方式绘制贴片内容,导致卡顿率很高。都放在主线程,性能天花板还是太低了。

不能在 worker 中进行flv 的 demuxing 操作吗?

参考 WebCodecs API 现有的方法, 我们会发现主要优化点就是把解码播放流程放在了 worker 中,那么自然我们想到,把 flv 的 demuxing 逻辑放到 worker 中,不也可以优化性能吗?

其实 flv.js 也确实已经做了类似的优化,但这里最大的问题是 MediaSource Extensions 只能在主线程调用,所以哪怕在 worker 中做了解包操作,解码和渲染还是得在主线程操作,这个过程难免涉及内存拷贝等操作,对性能有较大影响。

WebCodecs API 的最大价值在于 VideoDecoder 这些 API 也可以在 worker 中执行,再配合 canvas,使得从文件加载到解包最后解码播放,全都可以发生在 worker 进程中,最大限度避免 UI 卡顿。


以上就是关于 WebCodecs API 的介绍了,如果遇到视频播放卡顿的问题,可以尝试采用这种方式进行优化。