前言
对于前端来说,视频解析操作一直是一个比较薄弱的环节。虽然web端提供了很多视频接口,这些接口都比较高级,但是没有将编解码操作暴露出来,使得我们无法对视频进行帧级别的操作。在之前有此类需求通常会借助WebAssembly的能力,使用诸如ffmpeg这些三方库来进行操作,但是wasm也有自身的缺点(打包过大、性能一般),体验终究不如原生。
现在有了WebCodecs,将浏览器的视频编解码能力暴露出来,使得我们在前端对视频的处理方便多了。关于这个API的具体内容可以查看mdn文档 developer.mozilla.org/en-US/docs/...
开工
目标
从本地或者url获取一个mp4格式的视频,给视频添加水印后将视频下载到本地
基本思路
基本思路如上图,我们按照步骤一步一步来分做。本次我们先不处理音频,所以获取到的视频是没有声音的,声音的编解码留到之后在做
具体操作
解封装
MP4文件作为一个封装格式,内部既有音频轨和视频轨,我们要获得视频帧的第一步就是首先把视频流从文件中解析出来,这一步我们使用之前用到的MP4box.js。
第一步我们首先先把视频传给mp4box
JavaScript
// 这里使用网络视频,首先先fetch下来
const file = await fetch(
'https://lf3-static.bytednsdoc.com/obj/eden-cn/1eh7vhanuvoln/ght-test/0-3673208261256(1).mp4',
);
const arraybuffer = await file.arrayBuffer();
const mp4box = MP4Box.createFile();
// 监听onReady回调
mp4box.onReady = handleReady
// 将刚刚fetch好的arraybuffer传给mp4box并开始解析
arraybuffer!.fileStart = 0;
mp4box.appendBuffer(arraybuffer as any);
mp4box.flush();
在mp4box解析好后会触发我们传入的onReady回调,参数如下图
这些信息主要是mp4的文件信息,里边并没有视频流本体,我们需要在ready后传入onSamples回调,并触发start方法来获取到视频的sample(经过编码的一帧)的数据。mp4box是按照chunk解码的,一次会传入好几个sample
JavaScript
export async function handleReady(
info,
) {
console.log('info', info);
......
mp4box.onSamples = (trackId, ref, samples) => {
for (const sample of samples) {
}
};
}
sample的内容如下
可以看到这里边包含了每帧的实际数据(data)和一些诸如duration之类的基本信息,下一步我们就可以把他传给解码器进行解码了
解码
解码这里我们就要用到WebCodecs api提供的VideoEncoder了,VideoDecoder可以将编码过的视频帧解析成VideoFrame,即实际的像素帧。首先我们创建一个VideoEncoder
JavaScript
// in handleReady 回调
const videoDecoder = new VideoDecoder({
output: handleDecode,
error: err => {
console.error('videoDecoder错误:', err);
},
});
// 视频轨
const videoTrack = info.videoTracks[0];
const videoW = videoTrack.track_width;
const videoH = videoTrack.track_height;
const config = {
codec: videoTrack.codec,
codedWidth: videoW,
codedHeight: videoH,
description: getExtraData(mp4box, videoTrack),
};
const { supported } = await VideoDecoder.isConfigSupported(config);
if (!supported) { // 判断解析配置是否支持
console.error('NOT supported');
}
// 传入解析配置
videoDecoder.configure(config);
对于解码器所接受的config的字段,codedWidth和codedHeight都比较好理解,分别代指视频的宽和高。
而codec则是指定所使用的解码器,对于我们使用的MIME type为video/mp4的情况,codec的值是avc1.4d002a(内部封装的AVC,也就是H.264视频),在这里我们直接使用mp4box解析出来的值即可。具体可以参考mdn文档
而对于description,官方文档上的解释则是
An ArrayBuffer, a TypedArray, or a DataView containing a sequence of codec specific bytes, commonly known as extradata.
直译为传给解码器解析的特定序列,比较费解。我们看官方demo的getExtraData实现如下
JavaScript
function getExtraData(file: any, track: any) {
const trak = file.getTrackById(track.id);
for (const entry of trak.mdia.minf.stbl.stsd.entries) {
const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C;
if (box) {
const stream = new MP4Box.DataStream(
undefined,
0,
MP4Box.DataStream.BIG_ENDIAN,
);
box.write(stream);
return new Uint8Array(stream.buffer, 8); // Remove the box header.
}
}
}
可以看到实际上是取了mdia.minf.stbl.stsd box中的信息,从MP4格式简析我们知道,stsd box存取了解码所需的信息,这下就理解了,VideoDecoder解码果然不例外也需要这个值。
在创建好解码器后,我们就可以将视频流传给解码器了,这里解码器接受EncodedVideoChunk类型的数据
JavaScript
// in onSamples 回调
for (const sample of samples) {
const type = sample.is_sync ? 'key' : 'delta';
const chunk = new EncodedVideoChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data,
});
videoDecoder.decode(chunk);
}
其中type标识了这个帧是否是关键帧,直接取sample.is_sync字段即可
视频流传入解码器后,解析好的视频帧会以VideoFrame的格式通过之前传入的handleDecode回调传出,我们就可以进行下一步操作了
JavaScript
export function handleDecode(frame: VideoFrame) {
// 暂存一下
pendingFrames.push(frame);
if (underflow) {
setTimeout(renderFrame, 0);
}
}
加水印
加水印就比较简单了,VideoFrame可以被draw到canvas上去,直接使用canvas操作就可以
JavaScript
async function renderFrame() {
underflow = pendingFrames.length === 0;
if (underflow) {
return;
}
// 帧的解析和帧的实际时间并不对应,这里按照timestamp控制一下模拟视频速度进行播放
const frame = pendingFrames.shift()!;
const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
await new Promise(r => {
setTimeout(r, timeUntilNextFrame);
});
// 把视频帧先画在画布上
ctx.drawImage(frame, 0, 0);
// 把水印draw在画布上
drawWaterMarker(ctx, canvas);
// 不用的videoFrame及时close,避免内存泄漏
frame.close();
setTimeout(renderFrame, 0);
}
这里画水印的方法比较简单,就是把文字draw到画布上即可,具体就不介绍了。加好水印的效果如下
编码
带水印的视频已经成功的被draw到canvas上去了,下一步我们要将canvas画布上的内容编码成视频帧。
这一步要用到WebCodecs api提供的VideoEncoder。和VideoDecoder正相对的,VideoDecoder接受VideoFrame帧并将其编码为EncodedVideoChunk
首先我们新建一个VideoEncoder,和VideoDecoder差不多
JavaScript
const videoEncoder = new VideoEncoder({
output: handleEncode,
error: e => {
console.log('VideoEncoder', e.message);
},
});
const config = {
codec: videoTrack.codec,
width: videoW,
height: videoH,
bitrate: 2_000_000, // 2 Mbps
framerate: 30,
};
const { supported: enSupported } = await VideoEncoder.isConfigSupported(
config,
);
if (!enSupported) {
console.error('NOT enSupported');
}
videoEncoder.configure(config);
然后我们需要获取从canvas中创建当前帧VideoFrame,将每一帧传给编码器
JavaScript
// 这里特殊处理了一下将开头帧的timestamp设为0
const newFrame = new VideoFrame(canvas, {
timestamp: isfirst ? 0 : frame.timestamp,
});
isfirst = false;
videoEncoder.encode(newFrame);
// 记得close
newFrame.close();
开始解析后,handleEncode回调就会源源不断的接收到解析好的EncodedVideoChunk。下一步我们就是将EncodedVideoChunk封装成一个可播放的mp4文件。
封装(mux)
上一步的EncodedVideoChunk对应的实际上就是mp4中的一个chunk,我们要做的是要首先将chunk组装成视频流,然后将视频流作为一个视频轨道封装到mp4文件中。
这里介绍一下muxer这个说法,这实际是个缩写。视频音频流封装这个过程在英文中又叫multiplex(多路复用),简写就变成了mux,则封装器就是一个muxer/multiplexer(多路复用器)。同样demuxer的意思也比较明显了
封装chunk的工作并不简单,mp4box也没有提供封装功能,这里我们使用mp4-muxer来做这一步。首先新建一个muxer
JavaScript
const muxer = new Muxer({
target: new ArrayBufferTarget(),
video: {
codec: videoTrack.codec,
width: videoW,
height: videoH,
},
fastStart: 'in-memory',
});
然后将EncodedVideoChunk送给muxer,全部完成就可以获取到mp4文件
JavaScript
// in handleEncode
muxer.addVideoChunk(chunk, metadata);
// 编码完成后
muxer.finalize();
const { buffer } = muxer.target;
然后我们将这个视频下载到本地,就完成了
JavaScript
const blob = new Blob([buffer], { type: 'video/mp4' });
const a = document.createElement('a');
a.download = 'test' + '.mp4';
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);