浏览器中已经存在了许多和媒体相关的API,例如HTMLVideoElement
、MediaSourceExtensions
、WebAudio
等等,但这些都属于高级API,如果开发者想要对编解码过程进行控制单凭这些API有些力不从心。
想要进行底层操作也是可以的,通过Wasm引入ffmpeg,在浏览器进行编解码操作,但是浏览器本身已经有ffmpeg了,再进行引入有些冗余,而且通过Wasm的ffmpeg性能上也不如原生应用。
于是Chrome94之后,开放了更加底层的WebCodecs API,开发者可以对音视频进行底层的编解码操作,但是目前开放的支持的编解码格式是有限的。
下图大体描述了WebCodecs的一些使用流程:
解码所需的数据来源有两类,一种是文件,通过解析文件获得Buffer,然后构造EncodeVideoChunk或者EncodedAudioChunk(简称音视频数据),通过VideoDecoder或者AudioDecoder(简称解码器)对音视频数据进行解码,得到VideoFrame或者AudioData(简称帧数据)。另一种是输入设备捕获的实时流,通过MediaStreamTrackProcessor获取ReadableStream,通过reader读取到帧数据。
如果需要对视频进行编辑,可以将视频帧渲染到Canvas中进行操作,然后重新写入视频帧,再通过VideoEncoder和AudioEncoder(简称编码器)对帧数据进行编码得到音视频数据,最后将音视频数据进行封装或者播放。
🔔:刚接触WebCodecs大概率是懵逼的,研究两三天,动手操作一下就懂了。
在执行WebCodecs代码之前一定要检测当前浏览器是否支持WebCodecs
if('VideoEncoder' in window){
console.log("webcodecs is supported.")
}
因为还是实验性阶段,兼容性较差
视频的两种输入方式
Network/LocalFile
我们以MP4格式视频为例,这里借助下MP4Box.js
来进行解封装。
准备工作,使用http-server
来托管一个静态的mp4文件(Nginx也可以,就是有点麻烦)。
首先进行拉取文件并解封装
const file = MP4Box.createFile();
file.onReady = (info) => {
const videoTrack = info.videoTracks[0];
file.setExtractionOptions(videoTrack.id, 'video');
file.start();
}
file.onSamples = (id, user, samples) => {
for (const sample of samples) {
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? 'key' : 'delta',
timestamp: sample.cts,
duration: sample.duration,
data: sample.data
});
}
}
fetch('/video/beyond.mp4')
.then(res => res.arrayBuffer())
.then(buffer => {
buffer.fileStart = 0;
file.appendBuffer(buffer)
})
文件较小可以直接获取buffer,如果文件较大,或者是直播场景则需要通过流的方式读取
fetch('/video/beyond.mp4') .then(res => res.body?.getReader()) .then(async reader => { let size = 0; while (reader) { const { done, value } = await reader.read(); if (value) { const buffer = value.buffer buffer.fileStart = size; size += buffer.byteLength; file.appendBuffer(buffer); } if (done) break; } })
onReady事件中可以获取视频文件的一些信息,会在接下来初始化解码器的时候用到;onSamples表示获取到可用视频数据了,根据视频信息构造EncodedVideoChunk,如果是关键帧类型需要设置为key
,否则设置为delta
,构造出的chunk将会在下面使用。
import {DataStream} from 'mp4box'
let videoTrackId;
const videoFrames = [];
const getExtradata = (file: MP4Box.MP4File) => {
const entry = file.moov?.traks[0].mdia?.minf?.stbl?.stsd?.entries[0];
const box = entry?.avcC ?? entry?.hvcC ?? entry?.vpcC;
if (box != null) {
const stream = new DataStream(
undefined,
0,
DataStream.BIG_ENDIAN
);
box.write(stream);
return new Uint8Array(stream.buffer.slice(8));
}
};
const videoDecoder = new VideoDecoder({
output: (videoFrame) => {
createImageBitmap(videoFrame).then(img);
videoFrame.close();
});
},
error: (error) => {
console.log(error)
}
});
file.onReady = (info) => {
// 省略部分代码
const videoTrack = info.videoTracks[0];
videoTrackId = videoTrack.id;
if (videoTrack) {
videoDecoder.configure({
codec: videoTrack.codec,
codedWidth: videoTrack.track_width,
codedHeight: videoTrack.track_height,
description: getExtradata(file),
});
}
}
file.onSamples = (id, user, samples) => {
for (const sample of samples) {
// 省略部分代码
videoDecoder.decode(chunk);
}
}
function draw() {
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
const img = videoFrames.shift();
ctx.drawImage(img, 0, 0)
requestAnimationFrame(draw)
}
getExtradata用于添加元信息描述,如果不添加的话会报错
最终渲染效果如下。
MediaStream
代表性的两种媒体流就是摄像头和屏幕共享,我们选取屏幕共享来进行演示(二者操作完全一致)。
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
navigator.mediaDevices.getDisplayMedia({
video: {
width: 1920,
height: 1080,
},
})
.then(stream => {
const track = stream.getVideoTracks()[0]
const processor = new MediaStreamTrackProcessor({ track }).readable.getReader();
const processFrame = async () => {
const { value, done } = await processor.read();
console.log(done);
if (!done) {
const image = await createImageBitmap(value);
ctx.drawImage(image, 0, 0, 1920, 1080);
value.close();
processFrame();
}
}
processFrame()
}).catch((error) => {
console.error('Error accessing camera:', error);
});
过程相较于文件输入要简单不少,但是MediaStreamTrackProcessor
在VSCode中报错找不到,在浏览器中可以正常执行,原因不详,可能是实验性API没有被编辑器支持。
得到的效果如下
视频编辑
视频的编辑我们需要用到前端的瑞士军刀——Canvas来进行处理。下面我们会举几个常见的例子来演示下,更多的操作就发挥你的想象力吧。
镜像反转
通过canvas的反转操作来进行镜像操作,在解码器中,获取到VideoFrame的时候渲染到Canvas进行操作,然后再通过当前Canvas的状态获取ImageBitmap进行保存。
let cnt = 1;
const videoDecoder = new VideoDecoder({
output: (videoFrame) => {
if (transCtx) {
transCtx.clearRect(0, 0, canvas.width, canvas.height);
// 进行镜像反转
if (cnt === 1) {
transCtx.scale(1, -1);
transCtx.translate(0, -canvas.height);
}
transCtx.drawImage(videoFrame, 0, 0)
transCtx.font = '30px Arial';
transCtx.fillStyle = 'red';
transCtx.fillText('Hello World', 0, 100)
cnt++;
}
createImageBitmap(transCanvas).then((img) => {
videoFrames.current.push({ data: img, duration: videoFrame.duration, timestamp: videoFrame.timestamp });
videoFrame.close();
});
},
error: (error) => {
console.log(error)
}
});
最终实现的效果如下
视频消费
解析编辑完成之后就需要对视频数据进行消费,否则不是白忙一场了,常见的消费方式有两种:播放、保存(本地/网络保存)。
播放
关于播放,上面的示例中有过很多播放了,目前通过VideoFrame转ImageBitmap渲染到Canvas中是唯一的播放WebCodecs解码产物的方式,或许在未来能有更优雅的处理方式。
保存
通过遍历我们解码的视频帧,逐帧进行编码,然后通过MP4Box的封装能力对视频进行封装。
const videoEncodingTrackOptions = {
timescale: oneSecondInMicrosecond,
width: 1920,
height: 1080,
nb_samples: nbSampleTotal.current,
avcDecoderConfigRecord: null
};
console.log(videoFrameDurationInMicrosecond);
const videoEncodingSampleOptions = {
duration: videoFrameDurationInMicrosecond,
dts: 0,
cts: 0,
is_sync: false
};
let encodedVideoFrameCount = 0;
const outputFile = MP4Box.createFile();
let encodingVideoTrack = outputFile.addTrack(videoEncodingTrackOptions);
const encoder = new VideoEncoder({
output: (encodedChunk, config) => {
if (encodingVideoTrack == null) {
videoEncodingTrackOptions.avcDecoderConfigRecord =
config?.decoderConfig?.description;
encodingVideoTrack = outputFile.addTrack(
videoEncodingTrackOptions
);
}
const buffer = new ArrayBuffer(encodedChunk.byteLength);
encodedChunk.copyTo(buffer);
videoEncodingSampleOptions.dts = videoEncodingSampleOptions.cts =
encodedChunk.timestamp;
videoEncodingSampleOptions.is_sync = encodedChunk.type == "key";
outputFile.addSample(
encodingVideoTrack,
buffer,
videoEncodingSampleOptions
);
encodedVideoFrameCount++;
if (encodedVideoFrameCount == nbSampleTotal.current)
onVideoEncodingComplete();
},
error: (error) => {
console.log(error)
}
})
encoder.configure({
codec: 'avc1.4D0032',
width: 1920,
height: 1080,
bitrate: 15000000,
hardwareAcceleration: "prefer-hardware",
framerate: videoFramerate,
avc: { format: "avc" },
})
async function onVideoEncodingComplete() {
encoder.close();
outputFile.save('encode.mp4');
}
while (videoFrames.current.length) {
const { data, duration, timestamp } = videoFrames.current.shift();
const frame = new VideoFrame(data, { timestamp, duration })
encoder.encode(frame)
frame.close()
}
通过编码器将VideoFrame转为EncodedVideoChunk, 使用MP4Box对这些chunk进行封装,最后保存问文件,或者通过window.URL.createObjectURL创建地址进行播放。