前言
音视频工作流程" height="598" src="https://i-blog.csdnimg.cn/img_convert/f1da01f5e067276e1605673bd7600d6c.png" width="1610" />
在 WebCodecs 之前,由于编解码能力的缺失,几乎无法在纯浏览器中编辑、创建视频。
WebCodecs 补齐了编解码能力,相当于在浏览器中提供了视频创作能力。
预计 WebCodecs 将会像 HTML5 技术(Video、Audio、MSE...)一样对用户习惯带来巨大改变,HTML5 作用于视频消费端,WebCodecs 作用于视频生产端。
本章介绍如何在浏览器中创建视频
采集与编码
前面的文章已介绍过 WebCodecs 使用 VideoFrame、AudioData 来描述音视频原始数据。
常见的音视频源有:MediaStream(摄像头、麦克风、分享屏幕)、Canvas、Video 标签、文件流等...
第一步,将这些源对象转换成 VideoFrame、AudioData 对象,方法有:
- 使用 MediaStreamTrackProcessor (opens new window)将 MediaStream 转换为
ReadableStream<VideoFrame> 、 ReadableStream<AudioData>
,MDN 有示例代码 - 直接将 Canvas、Video 标签传递给 VideoFrame 的构建函数
new VideoFrame(canvas)
- 由解码器(VideoDecoder 、 AudioDecoder)解码本地或网络文件,得到 VideoFrame、AudioData
- 从 AudioContext (opens new window)获取音频原始数据创建 AudioData 对象,后续【音频数据处理】文章再介绍
第二步,将 VideoFrame、AudioData 传入编码器(VideoEncoder、AudioEncoder)
javascript">const encoder = new VideoEncoder({error: console.error,output: (chunk, meta) => {// chunk: EncodedVideoChunk 等待封装// meta 在下一步封装 SDK 创建轨道时需要},
});
encoder.configure({codec: 'avc1.4D0032', // H264width: 1280,height: 720,
});let timeoffset = 0;
let lastTime = performance.now();
setInterval(() => {const duration = (performance.now() - lastTime) * 1000;encoder.encode(new VideoFrame(canvas, {// 这一帧画面,持续 33ms,duration 单位 μsduration,timestamp: timeoffset,}));timeoffset += duration;
}, 33);
WARNING
当高频调用
encoder.encode
时应根据当前编码器的队列大小encoder.encodeQueueSize
决定是否需要暂停,队列中的 VideoFrame 数量过多会爆掉显存,导致性能极其低下
封装
编码器(VideoEncoder、AudioEncoder)将一帧帧原始数据编码(压缩)后会输出 EncodedVideoChunk、EncodedAudioChunk 对象,然后由封装程序将他们封装(muxing)成对应格式的视频文件。
我们继续使用 mp4box.js 来演示封装 mp4 文件。
MP4 将一个编码后的数据包抽象为 Sample,与 EncodedVideoChunk、EncodedAudioChunk 对象一一对应。
MP4 将不同类型的数据(音频、视频)分组抽象为 Track,分组管理不同类型的 Sample。
代码示例
javascript">const file = mp4box.createFile()
// 创建视频轨道
const videoTrackId = file.addTrack({timescale: 1e6,width: 1280,height: 720,// meta 来原于 VideoEncoder output 的参数avcDecoderConfigRecord: meta.decoderConfig.description
})
// 创建音频轨道
const audioTrackId = file.addTrack({timescale: 1e6,samplerate: 48000,channel_count: 2,type: 'mp4a' // AAC// meta 来原于 AudioEncoder output 的参数description: createESDSBox(meta.decoderConfig.description)
})/*** EncodedAudioChunk | EncodedVideoChunk 转换为 MP4 addSample 需要的参数*/
function chunk2MP4SampleOpts (chunk: EncodedAudioChunk | EncodedVideoChunk
): SampleOpts & {data: ArrayBuffer
} {const buf = new ArrayBuffer(chunk.byteLength)chunk.copyTo(buf)const dts = chunk.timestampreturn {duration: chunk.duration ?? 0,dts,cts: dts,is_sync: chunk.type === 'key',data: buf}
}// VideoEncoder output chunk
const videoSample = chunk2MP4SampleOpts(chunk)
file.addSample(videoTrackId, videoSample.data, videoSample)// AudioEncoder output chunk
const audioSample = chunk2MP4SampleOpts(chunk)
file.addSample(audioTrackId, audioSample.data, audioSample)
以上代码是为了将主要过程与 API 建立对应关系,实际上还需要比较复杂的流程控制逻辑,以及进一步了解 mp4 格式知识才能编写出完整可运行的程序。
TIP
- addSample 前必须保证音视频轨道(addTrack)都已经创建完成
- 创建音频轨道需要传递
description
(esds box),否则某些播放器将无法播放声音
生成文件流
使用 mp4box.js 封装编码器输出的数据,我们持有的是一个 MP4File 对象(mp4box.createFile()
),将 MP4File 对象转换成 ReadableStream
可以非常方便地写入本地文件、上传到服务器。
注意释放内存引用,避免内存泄露
代码不算太长,全部贴出来了
javascript">export function file2stream(file: MP4File,timeSlice: number,onCancel?: TCleanFn
): {stream: ReadableStream<Uint8Array>;stop: TCleanFn;
} {let timerId = 0;let sendedBoxIdx = 0;const boxes = file.boxes;const tracks: Array<{ track: TrakBoxParser; id: number }> = [];const deltaBuf = (): Uint8Array | null => {// boxes.length >= 4 表示完成了 ftyp moov,且有了第一个 moof mdat// 避免moov未完成时写入文件,导致文件无法被识别if (boxes.length < 4 || sendedBoxIdx >= boxes.length) return null;if (tracks.length === 0) {for (let i = 1; true; i += 1) {const track = file.getTrackById(i);if (track == null) break;tracks.push({ track, id: i });}}const ds = new mp4box.DataStream();ds.endianness = mp4box.DataStream.BIG_ENDIAN;for (let i = sendedBoxIdx; i < boxes.length; i++) {boxes[i].write(ds);delete boxes[i];}// 释放引用,避免内存泄露tracks.forEach(({ track, id }) => {file.releaseUsedSamples(id, track.samples.length);track.samples = [];});file.mdats = [];file.moofs = [];sendedBoxIdx = boxes.length;return new Uint8Array(ds.buffer);};let stoped = false;let canceled = false;let exit: TCleanFn | null = null;const stream = new ReadableStream({start(ctrl) {timerId = self.setInterval(() => {const d = deltaBuf();if (d != null && !canceled) ctrl.enqueue(d);}, timeSlice);exit = () => {clearInterval(timerId);file.flush();const d = deltaBuf();if (d != null && !canceled) ctrl.enqueue(d);if (!canceled) ctrl.close();};// 安全起见,检测如果start触发时已经 stopedif (stoped) exit();},cancel() {canceled = true;clearInterval(timerId);onCancel?.();},});return {stream,stop: () => {if (stoped) return;stoped = true;exit?.();},};
}
以上步骤,就是在浏览器中创建视频文件的全过程。
在 WebCodecs 之前,前端开发者只能在及其有限的场景使用 ffmpeg.wasm、MediaRecorder 创建视频文件。
现在利用 WebCodecs 则可以快速创建视频文件,并进行非常细致的帧控制,为多样的产品功能提供底层技术支持。
WebAV 生成视频示例
整个过程的原理不算难,文章的前两张图基本概括了,如果从零开始实现,还是有非常多的细节需要处理,以及更深入地学习一些 mp4 文件相关知识。
你可以略过细节,使用 @webav/av-cliper
提供的工具函数 recodemux 、 file2stream
来快速创建视频文件。
以下是从 canvas 创建视频的示例
javascript">import { recodemux, file2stream } from '@webav/av-cliper'const muxer = recodemux({video: {width: 1280,height: 720,expectFPS: 30},// 后续文章介绍如何处理音频数据audio: null
})let timeoffset = 0
let lastTime = performance.now()
setInterval(() => {const duration = (performance.now() - lastTime) * 1000muxer.encodeVideo(videonew VideoFrame(canvas, {// 这一帧画面,持续 33ms,duration 单位 μsduration,timestamp: timeoffset}))timeoffset += duration
}, 33)const { stream } = file2stream(muxer.mp4file, 500)
// upload or write stream
关于优联前端
武汉优联前端科技有限公司由一批从事前端10余年的专业人才创办,是一家致力于H5前端技术研究的科技创新型公司,为合作伙伴提供专业高效的前端解决方案,合作伙伴遍布中国及东南亚地区,行业涵盖广告,教育, 医疗,餐饮等。有效的解决了合作伙伴的前端技术难题,节约了成本,实现合作共赢。承接开发Web前端,微信小程序、小游戏,2D/3D游戏,动画交互与UI广告设计等各种技术研发。