浏览器中已经存在了许多和媒体相关的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创建地址进行播放。