Web Codecs-媒体编解码实践
前言
了解到这个API是来源于阿里云平台的开发者社区中一篇关于“Web端短视频编辑器的设计与实现”的介绍,这篇文章中介绍了如何设计一个视频编辑器,从架构到功能到实现,因此才生出通过代码去简单的实现他的这篇文章中描述的功能,然而在实现中缺少更加专业的音视频相关方向的专业知识,所以只能大概实现将一个视频解码到再编码导出的一个过程。
Web Codecs API简介
现代浏览器提供了很多种方式来播放或录制音视频,比如Media Stream API,Media Recoding API,Web Audio API,WebRTC API等等。然而,这些 API 不允许开发人员处理视频流的单个帧和未混合的编码音频或视频块。因此开发者通常使用 WebAssembly 来绕过此限制,并在浏览器中使用媒体编解码器。但是,这需要额外的带宽来下载浏览器中已经存在的编解码器,从而降低性能和能效,并增加额外的开发开销。WebCodecs API 提供对浏览器中已有的编解码器的访问。它可以访问原始视频帧、音频数据块、图像解码器、音频和视频编码器和解码器
视频处理工作流
那WebCodecs是如何工作的。
首先我们需要把视频文件解封装获得音视频块,然后通过WebCodecs的VideoDecoder和AudioDecoder解码分别获得VideoFrame(Frame是视频处理中的一个核心概念。一个Frame就是一帧,代表了视频中的一整幅画面。视频编码器将Frame转换为一个已经编码的数据块,解码器则把已经编码的数据块转化为Frame)和AudioData。
VideoFrame可以通过createImageBitmap方法将Frame转换成ImageBitmap,然后可以用于canvas的绘制——调用drawImage(),或者texImage2D()。这样的话,我们拿到解码后的Frame数据,就可以很方便的用canvas绘制出来。这个过程是很高效的。
AduioData可以通过MediaStream在前端应用中进行播放
在我们拿到了视频和音频数据后可以通过WebCodecs的VideoEncoder和AudioEncoder进行编码,然后通过Muxer工具对其进行封装导出mp4文件。整个工作的流程如下图。接下来针对这个流程详细讲解实现过程
一、视频解封装
音视频在解编码之前需要对视频文件进行解封装拿到视频文件的原始编码数据,而WebCodecs并没有提供相应的解封装API,因此就需要借助MP4Box.js/mux.js/ffmpeg 之类的工具拿到原始编码数据。在这里我们使用的是MP4Box.js
file = MP4Box.createFile(); file.onReady = (info: any) => { videoTrack = info.videoTracks[0]; audioTrack = info.audioTracks[0]; videoW = videoTrack.track_width; videoH = videoTrack.track_height; // 初始化解码器 setupVideoDecoder({ codec: videoTrack.codec, codedWidth: videoW, codedHeight: videoH, description: getExtradata(), }); setupAudioDecoder({ codec: audioTrack.codec, sampleRate: audioTrack.audio.sample_rate, numberOfChannels: audioTrack.audio.channel_count, }); file.start(); }) file.onSamples = (trackId: any, _ref: any, samples: string | any[]) => { if (videoTrack.id === trackId) { for (const sample of samples) { const type = sample.is_sync ? 'key' : 'delta'; const chunk = new window.EncodedVideoChunk({ type, timestamp: sample.cts, duration: sample.duration, data: sample.data, }); videoDecoder.decode(chunk); } if (samples.length === videoTrack.nb_samples) { videoDecoder.flush(); } return; } if (audioTrack.id === trackId) { for (const sample of samples) { const type = sample.is_sync ? 'key' : 'delta'; const chunk = new window.EncodedAudioChunk({ type, timestamp: sample.cts, duration: sample.duration, data: sample.data, offset: sample.offset, }); audioDecoder.decode(chunk); } if (samples.length === audioTrack.nb_samples) { audioDecoder.flush(); } } }) fetch(url).then((response) => { let offset = 0; let buf: any; const reader = response.body?.getReader(); const push = () => reader?.read().then(({ done, value }) => { if (done === true) { file.flush(); // 触发file.onReady return; } buf = value.buffer; buf.fileStart = offset; offset += buf.byteLength; file.appendBuffer(buf); push(); }).catch((e) => { console.error('reader error ', e); }); push(); });
首先通过MP4Box创建一个文件对象,然后通过fetch请求拿到文件的FileReader,通过读取FileReader的read方法获取文件流的buffer并将它添加到file对象中,在文件读取完成之后调用file的flush方法可以触发file的onReady,同时在onReady中会返回视频文件的轨道数据videoTracks和audioTracks,在file准备好之后执行file的start方法就是对视频文件的解封装,执行start方法后立即触发file的onSamples监听事件,这个事件一般有几条轨道就会触发几次,这里只有视频和音频各一条轨道,所以触发了两次,每次触发返回的值都是对应轨道的帧数据,之后遍历每个轨道的帧数据,分别通过EncodedAudioChunk和EncodedVideoChunk生成音视频的chunk块,之后就可以通过VideoDecoder和AudioDecoder对其进行解码获得每帧的数据
二、音视频解码
在我们通过视频解封装拿到了音频和视频的chunk数据之后,我们就可以通过window的VideoDecoder和AudioDecoder两个api分别对视频图像和音频进行解码,一般我们会将初始化解码器放在file对象的onReady方法中去初始化,在file文件加载完成并且解析出chunk数据之后就可以直接对chunk数据进行解码videoDecoder.decode(chunk);
videoW = videoTrack.track_width; videoH = videoTrack.track_height; setupVideoDecoder({ codec: videoTrack.codec, // 解码格式 codedWidth: videoW, // 解码数据宽度 codedHeight: videoH, // 解码数据高度 description: getExtradata(), // AVCDecoderConfigurationRecord(AVC Squence Header),包含H.264解码相关信息,如果是avc格式,需要提供,具体的解释需要去了解H.264 解码器avcC }); setupAudioDecoder({ codec: audioTrack.codec, // 解码格式 sampleRate: audioTrack.audio.sample_rate, // 音频采样率 numberOfChannels: audioTrack.audio.channel_count, // 音频通道数 }); // 视频解码 const setupVideoDecoder = (config: any) => { output.width = outputW; output.height = outputH; videoDecoder = new window.VideoDecoder({ output: (videoFrame: any) => { createImageBitmap(videoFrame).then((img) => { videoFrames.push(img); videoFrame.close(); // 因为后续只需要对img操作,所以我们使用完后需要释放掉视频帧的资源 // 在canvas中绘画第一帧 if (videoFrames.length === videoTrack.nb_samples) { setTimeout(() => { drawVideoImage(videoFrames[0]); }, 150); } }); }, error: (e: any) => { console.error('VideoDecoder error : ', e); }, }); videoDecoder.configure(config); file.setExtractionOptions(videoTrack.id, null, { nbSamples: nbSampleMax }); // 设置解码选项,这里设置的单次解码最大帧数 }; // 音频解码 const setupAudioDecoder = (config: any) => { audioDecoder = new window.AudioDecoder({ output: (audioFrame: any) => { decodedAudioFrames.push(audioFrame); if (decodedAudioFrames.length === audioTrack.nb_samples) { console.log(decodedAudioFrames) } }, error: (err: any) => { console.error('AudioDecoder error : ', err); }, }); audioDecoder.configure(config); file.setExtractionOptions(audioTrack.id, null, { nbSamples: nbSampleMax }); };
视频解码器初始化在传入我们需要的配置后即可完成初始化,之后在调用decode传入chunk数据在output回调方法中可以返回处理后的视频帧videoFrame,这里我们通过createImageBitmap方法将每个视频帧处理为图片数据,这样方便我们后面绘制到canvas,之后将我们解码出来的数据存入数组中,音频解码器和视频解码器方法类似,只是传入的配置数据不一样,解码chunk数据后也能获得音频数据audioFrame(AduioData)
三、音视频帧数据播放
如果我们是开发一个视频播放器或者是视频剪辑的话,在我们对音视频解码完成之后可以通过图像和声音的形式对解码后的数据进行预览播放
const playVideo = (start: number = 0) => { ctx?.clearRect(0, 0, outputW, outputH) const playLoop = (index: number) => { const imageBitmap = videoFrames[index]; if (index === (videoFrames.length - 1)) { currentTime = 0 } if (!imageBitmap) return; setTimeout(() => { drawVideoImage(imageBitmap) if (PAUSE) { currentTime = videoFrameDurationInMicrosecond * index } else { playLoop(index + 1) } }, videoFrameDurationInMicrosecond / 1000 ); } drawVideoImage(videoFrames[start]) playLoop(start + 1) } const playAudio = async (start: number = 0) => { if (start === 0) { const ele = document.getElementsByClassName('audio-play-ele')?.[0]; if (ele) { document.body.removeChild(ele) } const audio = document.createElement('audio'); audio.style.display = 'none'; audio.autoplay = true; audio.className = 'audio-play-ele' document.body.appendChild(audio); const generator = new window.MediaStreamTrackGenerator({ kind: 'audio' }); const { writable } = generator; writer = writable.getWriter(); const mediaStream = new MediaStream([generator]); audio.srcObject = mediaStream; } for (let index = start; index < decodedAudioFrames.length; index++) { const audioFrame = decodedAudioFrames[index].clone(); const timeout = setTimeout(() => { writer.write(audioFrame); audioFrame.close() clearTimeout(timeout) }, audioFrame.timestamp / 1000 ); } }
视频的播放较为简单,通过遍历循环视频帧,再加上定时器,通过canvas按顺序绘制每一帧的视频图像,在视觉上就形成了视频播放效果,
音频的播放需要借助audio标签,以及系统MediaStream和MediaStreamTrackGenerator,利用循环遍历和定时器循环将音频数据不断写入到writable中,WritableStream允许将媒体帧写入MediaStreamTrackGenerator,它本身就是一个MediaStreamTrack。如果kind属性是“audio”,流会接受AudioFrame对象,不接受任何其他类型的对象。再将MediaStreamTrackGenerator初始化到MediaStream中,然后使用audio的srcObject属性播放音频流
四、音视频编码
编码过程可以看作是解码过程的一个反过程
因为在前面我们把videoFrame转成了image bitmap,所以编码的时候我们还需要将image bitmap再转回videoFrame,这里我们使用window的VideoFrame方法进行转换
const imageBitmap = videoFrames[index] const timestamp = videoFrameDurationInMicrosecond * index; const videoFrame = new window.VideoFrame(imageBitmap, { timestamp, duration: videoFrameDurationInMicrosecond });
转换得到的videoFrame,经过VideoEncoder
编码后,都会生成一个EncodedVideoChunk
对象。首先,我们创建一个VideoEncode
对象,并且进行初始化。
setupVideoEncoder({ codec: 'avc1.42001E', //指定编码格式 width: outputW, height: outputH, hardwareAcceleration: 'prefer-software', // 硬件加速 framerate: videoFramerate, //每秒帧率 bitrate: BITRATE, //比特率 // avc: { format: 'avc' }, }); const setupVideoEncoder = (config: any) => { videoEncoder = new window.VideoEncoder({ output: (encodedChunk: any, config: any) => { //处理编码块 }, error: (err: any) => { console.error('VideoEncoder error : ', err); }, }); videoEncoder.configure(config);//配置编码参数 }
现在我们可以用进行编码了。通过encode()
方法,可以传递数据到编码器。encode()
会马上返回,并且不会立即返回处理结果。结果我们要在初始化参数output
的回调中获取。可以同时调用多次encode()
方法,编码器维护一个队列,按照先进先出的原则逐步处理。
有些异常会在调用encode()
时立即抛出,有些异常会在回调error
里处理,这里要注意区分一下。如果处理成功了,我们就可以在output
回调里拿到编码后的数据了
音频的编码过程和视频编码过程一致,只是传入配置编码参数不一样
setupAudioEncoder({ codec: 'opus', //指定编码格式,Chrome 的 WebCodecs AudioEncoder现在只支持opus格式 sampleRate: audioTrack.audio.sample_rate, // 音频采样率 numberOfChannels: audioTrack.audio.channel_count, // 通道数 bitrate: audioTrack.bitrate, // 比特率 }); const setupAudioEncoder = (config: any) => { audioEncoder = new window.AudioEncoder({ output: (encodedChunk: any, _config: any) => { }, error: (err: any) => { console.error('AudioEncoder error : ', err); }, }); audioEncoder.configure(config); };
注意:目前Chrome 的 WebCodecs AudioEncoder现在只支持opus格式,并且该格式导出的音频也只能在浏览器中才能正常播放,系统的媒体播放器本身不支持opus格式
五、视频导出
在所有的视频帧都编码完毕后,我们就需要对编码后的数据进行封装了,本文将其封装为最常用的视频格式——mp4。在这里我是利用mp4box的封装器,首先我们需要先创建一个输出的mp4box的文件对象const outputFile = MP4Box.createFile();
,之后我们对编码过程中获得的chunk数据进行封装
首先是对视频帧处理:
const videoEncodingTrackOptions = { timescale: ONE_SECOND_IN_MICROSECOND, // 文件媒体在1秒时间内的刻度,可理解为1s长度的时间单元数 width: outputW, height: outputH, nb_samples: videoTrack.nb_samples, media_duration: videoTrack.nb_samples * 1000 / FPS, brands: ['isom', 'iso2', 'avc1', 'mp41'], // 兼容性的版本 avcDecoderConfigRecord: null, }; const videoEncodingSampleOptions = { duration: videoFrameDurationInMicrosecond, dts: 0, cts: 0, is_sync: false, }; if (encodingVideoTrack == null) { videoEncodingTrackOptions.avcDecoderConfigRecord = config.decoderConfig.description; encodingVideoTrack = outputFile.addTrack(videoEncodingTrackOptions); } const buffer = new ArrayBuffer(encodedChunk.byteLength); encodedChunk.copyTo(buffer); videoEncodingSampleOptions.dts = encodedVideoFrameCount * MICROSECONDS_PER_FRAME; videoEncodingSampleOptions.cts = encodedVideoFrameCount * MICROSECONDS_PER_FRAME; videoEncodingSampleOptions.is_sync = encodedChunk.type === 'key'; outputFile.addSample(encodingVideoTrack, buffer, videoEncodingSampleOptions); encodedVideoFrameCount++; if (encodedVideoFrameCount === videoFrames.length) { onVideoEncodingComplete(); } else { encodeVideo(encodedVideoFrameCount) // 遍历所有视频帧 }
首先处理第一帧的时候需要在outputFile中添加一个视频轨道encodingVideoTrack,然后将encodedChunk转换为ArrayBuffer对象,最后利用mp4box的addSample方法将编码后的帧数据插入到视频轨道。
音频数据的处理和视频数据的处理方式一样,也是首先添加一个音频轨道,然后将encodedChunk转换为ArrayBuffer对象,最后利用mp4box的addSample方法将编码后的帧数据插入到音频轨道。
const audioEncodingTrackOptions = { timescale: SAMPLE_RATE, media_duration: 0, duration: 0, nb_samples: 0, samplerate: SAMPLE_RATE, width: 0, height: 0, hdlr: 'soun', name: 'SoundHandler', type: 'Opus', }; const audioEncodingSampleOptions = { duration: 0, dts: 0, cts: 0, is_sync: false, }; if (encodingAudioTrack === null) { totalaudioEncodeCount = Math.floor(audioTotalTimestamp / encodedChunk.duration); audioEncodingTrackOptions.nb_samples = totalaudioEncodeCount; const trackDuration = audioTotalTimestamp / ONE_SECOND_IN_MICROSECOND; audioEncodingTrackOptions.duration = trackDuration * SAMPLE_RATE; audioEncodingTrackOptions.media_duration = trackDuration * SAMPLE_RATE; encodingAudioTrack = outputFile.addTrack(audioEncodingTrackOptions); } const buffer = new ArrayBuffer(encodedChunk.byteLength); encodedChunk.copyTo(buffer); const sampleDuration = encodedChunk.duration / ONE_SECOND_IN_MICROSECOND * SAMPLE_RATE; audioEncodingSampleOptions.dts = encodedAudioFrameCount * sampleDuration; audioEncodingSampleOptions.cts = encodedAudioFrameCount * sampleDuration; audioEncodingSampleOptions.duration = sampleDuration; audioEncodingSampleOptions.is_sync = encodedChunk.type === 'key'; outputFile.addSample(encodingAudioTrack, buffer, audioEncodingSampleOptions); encodedAudioFrameCount++; if (encodedAudioFrameCount >= totalaudioEncodeCount) { console.log(encodedAudioFrameCount) onAudioEncodingComplete(); }
完成音视频的处理之后直接调用outputFile的save方法即可保存出视频文件 outputFile.save('test.mp4');
六、总结
这里主要是介绍了web codecs的相关API的使用,其中有许多音视频相关的知识不做详细介绍,因为这方面的知识点比较庞大,暂时没办法对它进行详细的讲解。
对于WebCodecs来说,它提供了我们访问底层编码解码接口的能力,使我们可以更深入的进行web音视频开发,是音视频开发的又一利器。但是,现在这个标准还没完全确定,有些还在调整中。而且很多API兼容性较差,目前应该是还没办法投产,不过在它的帮助下我们可以使用ffmpeg等工具用目前的解封装方式结合当前的音视频编解码去进行音视频的开发