Posts WebCodecs对音视频进行编码解码
Post
Cancel

WebCodecs对音视频进行编码解码

WebCodecs

  • 草案:https://wicg.github.io/web-codecs/
  • Github:https://github.com/WICG/web-codecs

允许 Web 应用程序对音频和视频进行编码和解码的 API

在 Chrome >= 86 的版本进行体验

  • Chrome地址栏输入:chrome://flags/#enable-experimental-web-platform-features,设置成 Enabled
  • 通过命令行启用 Chrome:/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --enable-blink-features=WebCodecs
1
2
3
4
// 通过 VideoEncoder API 检查当前浏览器是否支持
if ('VideoEncoder' in window) {
  // 支持 WebCodecs API
}

Web 编解码器 API 出现的原因

现在已经有很多 Web API 进行媒体操作: Media Stream API, Media Recording API, Media Source APIWebRTC API,但是没有提供一些底层 API 给到 Web 开发者进行帧操作或者对已经编码的视频进行解封装操作。

很多音视频编辑器为了解决这个问题,使用了 WebAssembly 把音视频编解码带到了浏览器,但是有个问题是现在的浏览器很多已经在底层支持了音视频编解码,并且还进行了很多硬件加速的调优,如果使用 WebAssembly 重新打包这些能力,似乎浪费人力和计算机资源。

所以就诞生了 WebCodecs API,暴露媒体 API 来使用浏览器已经有的一些能力,例如:

  • 视频和音频解码
  • 视频和音频编码
  • 原始视频帧
  • 图像解码器

这时候一些 web 媒体开发项目例如视频编辑器、视频会议、视频流的操作就方便多了

项目进展查看:https://github.com/WICG/web-codecs

WebCodecs 处理流程

frames 是视频处理的核心,因此 WebCodecs 大多数类要么消耗 frames 要么生产 frames。Video encoders 把 frames 转换为 encoded chunks,Video Decoders 把 encoded chunks 转换为 frames。这一切都在非主线程异步处理,所以可以保证主线程速度。

当前,在 WebCodecs 中,在页面上显示 frame 的唯一方法是将其转换为 ImageBitmap 并在 canvas 上绘制位图或将其转换为 WebGLTexture

Webcodecs 实际应用

Encoding

现在有两种方法把已经存在的图片转换为 VideoFrame

  • 一种是通过 ImageBitmap 创建 frame
  • 第二种是通过 VideoTrackReader 设置方法来处理从 [MediaStreamTrack](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack) 产生的 frame,当需要从摄像机或屏幕捕获视频流时,这个 API 很有用

ImageBitmap

01.png

1
2
3
4
5
let cnv = document.createElement('canvas');
// draw something on the canvas
...
let bitmap = await createImageBitmap(cnv);
let frameFromBitmap = new VideoFrame(bitmap, { timestamp: 0 });

第一种是直接从 ImageBitmap 创建 frame。只需调用 new VideoFrame() 构造函数并为其提供 bitmap 和展示时间戳

VideoTrackReader

02.png

1
2
3
4
5
6
const framesFromStream = [];
const stream = await navigator.mediaDevices.getUserMedia({  });
const vtr = new VideoTrackReader(stream.getVideoTracks()[0]);
vtr.start((frame) => {
  framesFromStream.push(frame);
});

使用 VideoEncoder 将 frame 编码为 EncodedVideoChunk 对象,VideoEncoder 需要两个对象:

  • 带有 outputerror 两个方法的初始化对象,传递给 VideoEncoder 后无法修改
  • Encoder 配置对象,其中包含输出视频流的参数。可以稍后通过调用 configure() 来更改这些参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  }
};

let config = {
  codec: 'vp8',
  width: 640,
  height: 480,
  bitrate: 8_000_000, // 8 Mbps
  framerate: 30,
};

let encoder = new VideoEncoder(init);
encoder.configure(config);

设置好 encoder 后,可以接受 frames 了,当开始从 media stream 接受 frames 的时候,传递给 VideoTrackReader.start() 的 callback 就会被执行,把 frame 传递给 encoder,需要定时检查 frame 防止过多的 frames 导致处理问题。注意:encoder.configure()encoder.encode() 会立即返回,不会等待真正处理完成。如果处理完成 output()方法会被调用,入参是 encoded chunk。再注意:encoer.encode() 会消耗掉 frame,如果 frame 需要后面使用,需要调用 clone 来复制它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let frameCounter = 0;
let pendingOutputs = 0;
const vtr = new VideoTrackReader(stream.getVideoTracks()[0]);

vtr.start((frame) => {
  if (pendingOutputs > 30) {
    // 有太多帧正在处理中,编码器承受不过来,不添加新的处理帧了
    return;
  }
  frameCounter++;
  pendingOutputs++;
  const insert_keyframe = frameCounter % 150 === 0;
  encoder.encode(frame, { keyFrame: insert_keyframe });
});

最后就是完成 handleChunk 方法,通常,此功能是通过网络发送数据块或将它们封装到到媒体容器中

1
2
3
4
5
6
7
8
9
10
11
12
function handleChunk(chunk) {
  let data = new Uint8Array(chunk.data);  // actual bytes of encoded data
  let timestamp = chunk.timestamp;        // media time in microseconds
  let is_key = chunk.type == 'key';       // can also be 'delta'
  pending_outputs--;
  fetch(`/upload_chunk?timestamp=${timestamp}&type=${chunk.type}`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/octet-stream' },
    body: data
  });
}

有时候需要确保所有 pending 的 encoding 请求完成,调用 flush()

1
await encoder.flush();

Decoding

设置 VideoDecoder 和上面类似,需要传递 initconfig 两个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const decoder = new VideoDecoder(init);
decoder.configure(config);

设置好 decoder 之后就可以给它喂 EncodedVideoChunk 对象了,通过 [BufferSouce](https://developer.mozilla.org/en-US/docs/Web/API/BufferSource) 来创建 chunk

1
2
3
4
5
6
7
8
9
10
const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

03.png

渲染 decoded frame 到页面上分为三步:

  • VideoFrame 转换为 [ImageBitmap](https://developer.mozilla.org/en-US/docs/Web/API/ImageBitmap)
  • 等待合适的时机显示 frame
  • 将 image 画到 canvas 上

当一个 frame 不再需要的时候,调用 destroy() 在垃圾回收之前手动销毁他,这可以减少页面内存占用

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
const cnv = document.getElementById("canvas_to_render");
const ctx = cnv.getContext("2d", { alpha: false });
const readyFrames = [];
let underflow = true;
let timeBase = 0;

function handleFrame(frame) {
  readyFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function delay(time_ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, time_ms);
  });
}

function calculateTimeTillNextFrame(timestamp) {
  if (timeBase == 0) timeBase = performance.now();
  const media_time = performance.now() - timeBase;
  return Math.max(0, timestamp / 1000 - media_time);
}

async function renderFrame() {
  if (readyFrames.length === 0) {
    underflow = true;
    return;
  }
  const frame = readyFrames.shift();
  underflow = false;

  const bitmap = await frame.createImageBitmap();
  frame.destroy();
  // 根据帧的时间戳,计算在显示下一帧之前需要的实时等待时间
  const timeTillNextFrame = calculateTimeTillNextFrame(frame.timestamp);
  await delay(timeTillNextFrame);
  ctx.drawImage(bitmap, 0, 0);

  // 立即下一帧渲染
  setTimeout(renderFrame, 0);
}

Demo

体验地址:https://ringcrl.github.io/static/webcodecs-demo/

如果打不开参考上面【在 Chrome >= 86 的版本进行体验】

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
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <style>
    canvas {
      padding: 10px;
      background: gold;
    }

    button {
      background-color: #555555;
      border: none;
      color: white;
      padding: 15px 32px;
      width: 150px;
      text-align: center;
      display: block;
      font-size: 16px;
    }
  </style>
</head>

<body>
  <button onclick="playPause()">Pause</button>
  <canvas id="dst" width="640" height="480"></canvas>
  <canvas style="visibility: hidden;" id="src" width="640" height="480"></canvas>
  <script src="./main.js"></script>

</body>

</html>
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
const codecString = "vp8";
let keepGoing = true;

function playPause() {
  keepGoing = !keepGoing;
  const btn = document.querySelector("button");
  if (keepGoing) {
    btn.innerText = "Pause";
  } else {
    btn.innerText = "Play";
  }
}

function delay(time_ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, time_ms);
  });
}

async function startDrawing() {
  const cnv = document.getElementById("src");
  const ctx = cnv.getContext("2d", { alpha: false });

  ctx.fillStyle = "white";
  const { width } = cnv;
  const { height } = cnv;
  const cx = width / 2;
  const cy = height / 2;
  const r = Math.min(width, height) / 5;
  const drawOneFrame = function drawOneFrame(time) {
    const angle = Math.PI * 2 * (time / 5000);
    const scale = 1 + 0.3 * Math.sin(Math.PI * 2 * (time / 7000));
    ctx.save();
    ctx.fillRect(0, 0, width, height);

    ctx.translate(cx, cy);
    ctx.rotate(angle);
    ctx.scale(scale, scale);

    ctx.font = "30px Verdana";
    ctx.fillStyle = "black";
    const text = "😊📹📷Hello WebCodecs 🎥🎞️😊";
    const size = ctx.measureText(text).width;
    ctx.fillText(text, -size / 2, 0);
    ctx.restore();
    window.requestAnimationFrame(drawOneFrame);
  };
  window.requestAnimationFrame(drawOneFrame);
}

function captureAndEncode(processChunk) {
  const cnv = document.getElementById("src");
  const fps = 60;
  let pendingOutputs = 0;
  let frameCounter = 0;
  const stream = cnv.captureStream(fps);
  const vtr = new VideoTrackReader(stream.getVideoTracks()[0]);

  const init = {
    output: (chunk) => {
      pendingOutputs--;
      processChunk(chunk);
    },
    error: (e) => {
      console.log(e.message);
      vtr.stop();
    },
  };

  const config = {
    codec: codecString,
    width: cnv.width,
    height: cnv.height,
    bitrate: 10e6,
    framerate: fps,
  };

  const encoder = new VideoEncoder(init);
  encoder.configure(config);

  vtr.start((frame) => {
    if (!keepGoing) return;
    if (pendingOutputs > 30) {
      // Too many frames in flight, encoder is overwhelmed
      // let's drop this frame.
      return;
    }
    frameCounter++;
    pendingOutputs++;
    const insert_keyframe = frameCounter % 150 === 0;
    encoder.encode(frame, { keyFrame: insert_keyframe });
  });
}

async function frameToBitmapInTime(frame, timeout_ms) {
  const options = { colorSpaceConversion: "none" };
  const convertPromise = frame.createImageBitmap(options);

  if (timeout_ms <= 0) return convertPromise;

  const results = await Promise.all([convertPromise, delay(timeout_ms)]);
  return results[0];
}

function startDecodingAndRendering() {
  const cnv = document.getElementById("dst");
  const ctx = cnv.getContext("2d", { alpha: false });
  const readyFrames = [];
  let underflow = true;
  let timeBase = 0;

  function calculateTimeTillNextFrame(timestamp) {
    if (timeBase === 0) timeBase = performance.now();
    const mediaTime = performance.now() - timeBase;
    return Math.max(0, timestamp / 1000 - mediaTime);
  }

  async function renderFrame() {
    if (readyFrames.length === 0) {
      underflow = true;
      return;
    }
    const frame = readyFrames.shift();
    underflow = false;

    const bitmap = await frame.createImageBitmap();
    // 根据帧的时间戳,计算在显示下一帧之前需要的实时等待时间
    const timeTillNextFrame = calculateTimeTillNextFrame(frame.timestamp);
    await delay(timeTillNextFrame);
    ctx.drawImage(bitmap, 0, 0);

    // 立即下一帧渲染
    setTimeout(renderFrame, 0);
    frame.destroy();
  }

  function handleFrame(frame) {
    readyFrames.push(frame);
    if (underflow) {
      underflow = false;
      setTimeout(renderFrame, 0);
    }
  }

  const init = {
    output: handleFrame,
    error: (e) => {
      console.log(e.message);
    },
  };

  const config = {
    codec: codecString,
    codedWidth: cnv.width,
    codedHeight: cnv.height,
  };

  const decoder = new VideoDecoder(init);
  decoder.configure(config);
  return decoder;
}

function main() {
  if (!("VideoEncoder" in window)) {
    document.body.innerHTML = "<h1>WebCodecs API is not supported.</h1>";
    return;
  }
  startDrawing();
  const decoder = startDecodingAndRendering();
  captureAndEncode((chunk) => {
    decoder.decode(chunk);
  });
}

document.body.onload = main;

参考地址

Video processing with WebCodecs

This post is licensed under CC BY 4.0 by the author.
Trending Tags
Contents

通过babel-plugin校验封装第三方提供的vue组件

PS-AE-Sketch

Trending Tags