最近公司想要举办一个粉丝群活动,需要实现在手机端给自拍视频增加相框、装饰的效果、并能保存下来,而且需要保留视频中的声音。
首先参考的是微信小程序中提供的videoDecoder,经过实验,微信小程序中的videoDecoder只能截取视频的一部分,所以无法实际使用。然后考虑H5中使用ffmpeg,使用其加水印功能。然后还有一个淘汰的选择:就是Webcodecs。虽然这个选择因为浏览器兼容问题被淘汰了,但是它仍然是一个值得被关注和收藏的技术。
Webcodecs是个实验性的API(2021年随着chrome 94发布),它提供了对浏览器中已存在的编解码器能力,可以解析原始视频帧、音频数据。有了Webcodecs,可以在浏览器中完成视频压缩、截取、增加特效、剪辑、格式转化等功能。虽然Webcodecs当前不被所有浏览器支持、但是已经有越来越多的浏览器开始采用了Webcodecs作为视频处理的标准组件。
上图是最新的Webcodecs文档中一个关键接口VideoDecoder中,对于浏览器兼容列举的部分截图,对于Chrome和Edge这2个浏览器,在PC和Mac上都测试过没有运行问题;而Mac上的Safari是要新版本(16.4是2023年3月发布的)运行的。
下面对于如何实现一个视频加贴图水印并导出的前端demo做详细说明。编写代码时,参考了一些已有的相关博客、文档和社区帖子的代码片段和说明,先列举在此。
- mp4box.js加WebCodecs 解码MP4视频帧并渲染
- 「1.4万字」玩转前端 Video 播放器 | 多图预警
- muxer 文档
- 一个使用webm-muxer(和WebCodes)的demo
- 一个英语的讨论mp4box、WebCodes处理视频(包含audio)的帖子
本文介绍的demo页面,可见元素包含显示视频帧和覆盖贴图(水印)的画布、合成后的视频以及选择源视频、覆盖贴图图片和执行重编码这3个按钮。视频只支持MP4格式、图片可以是png、jpg和gif(会自动使用第1帧)。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MP4Box WebCodecs 实验</title>
<script src="./mp4box.all.min.js"></script>
<script src="./webm-muxer.js"></script>
</head>
<body>
<h3>效果:</h3>
<canvas id="canvas" width="360" height="640"></canvas>
<h3>结果预览:</h3>
<video id="result_view" controls><source id="videoSource" src="" type="video/mp4"></video>
<p>
<input type="file" id="chooseVideoInput" style="display:none" accept="video/mp4" onchange="handleFiles(this.files)">
<button onclick="chooseVideo()">选择MP4视频并解码</button>
<input type="file" id="chooseImgInput" style="display:none" accept="image/*" onchange="handleFiles(this.files)">
<button onclick="chooseImg()">选择覆盖层图片</button>
<button id="run">重编码MP4并保存</button>
</p>
<script>
......见后文 脚本第1~6部分
</script>
</body>
</html>
上面html头中引入了mp4box.all.min.js和webm-muxer.js这2个js库,webm-muxer.js可以在webm-muxer的demo页面中从Network调试面板中复制出代码(如下图)。mp4box.all.min.js可以下载https://cdn.jsdelivr.net/npm/mp4box@0.5.2/dist/mp4box.all.min.js获取。
深入JS脚本前,先把流程图附上,可以看出WebcodecsAPI的使用很简洁,而且数据和算法都是对称的。
脚本第1部分:检查浏览器是否支持 Webcodecs
脚本最开始查看当前环境是否支持VideoEncoder(浏览器是否兼容)、然后反馈查询结果。在Chrome和Edge本地调试和部署到服务器应该都可以通过校验、并且能顺利运行。注意,在服务器部署页面需要使用https而非http协议(见Webcodecs文档)。
if('VideoEncoder' in window){
console.log("webcodecs is supported.")
}else{
console.error("webcodecs is not supported.")
alert("webcodecs is not supported.")
}
脚本第2部分:视频、覆盖贴图图片的路径声明与获取
let mp4url = '';
let coverImg = new Image();
coverImg.onload = function(e) {
//console.log(coverImg.width);
};
function chooseVideo() {
document.getElementById('chooseVideoInput').click();
}
function chooseImg() {
document.getElementById('chooseImgInput').click();
}
function handleFiles(files) {
// 处理选中的文件
//console.log(files);
if(files[0].type.indexOf('image')==0){
coverImg.src = URL.createObjectURL(files[0]); // 覆盖层图片路径赋值
}else if(files[0].type.indexOf('video/mp4')==0){
mp4url = URL.createObjectURL(files[0]); // 选中视频路径赋值
console.log(mp4url);
// 调用初始化视频处理
StartDecode();
}else{
alert('不支持的文件格式');
}
}
视频、覆盖贴图图片的路径获取事件绑定到了html的按钮和隐藏input上了,点击按钮可以在本地磁盘中选择视频和覆盖图片。选择完视频,即可开始视频解码,调用的StartDecode函数将在后面定义。
脚本第3部分:视频处理关键全局变量的声明,处理方法声明与执行(先解编码视频,脚本第5部分再解编码音频)
// mp4box file
let mp4box = null;
// 视频轨道,解、编码器
let videoTrack = null;
let videoDecoder = null;
let videoEncoder = null;
// 这个就是最终解码出来的视频画面序列文件
let videoFrames = [];
// 采样上限
const NBSampleMax = 1000;
// 视、音频公用变量
let nbSampleTotal = 0;
let countSample = 0;
let videoDuration = 0;
// 视频的宽度和高度
let videoW = 360;
let videoH = 640;
// 音频轨道,解、编码器,帧
let audioTrack = null;
let audioDecoder = null;
let audioEncoder = null;
let decodedAudioFrames = [];
// 封装器
let muxer = null;
// 视频路径获取后,可以开始初始化 mp4box、执行视频解码
async function StartDecode(){
// 下面是视频解码的处理逻辑,使用mp4box.js获取视频信息
// 使用 Webcodecs API 进行解码
mp4box = MP4Box.createFile();
// 获取视频的arraybuffer数据,启动mp4box
fetch(mp4url).then(res => res.arrayBuffer()).then(buffer => {
// 因为文件较小,所以直接一次性写入
// 如果文件较大,则需要res.body.getReader()创建reader对象,每次读取一部分数据
// reader.read().then(({ done, value })
buffer.fileStart = 0;
mp4box.appendBuffer(buffer);
mp4box.flush();
});
// 这个是额外的处理方法,不需要关心里面的细节
const getExtradata = () => {
// 生成VideoDecoder.configure需要的description信息
const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];
const box = entry.avcC ?? entry.hvcC ?? entry.vpcC ?? entry.av1C;
if (box != null) {
const stream = new DataStream(
undefined,
0,
DataStream.BIG_ENDIAN
)
box.write(stream)
// slice()方法的作用是移除moov box的header信息
return new Uint8Array(stream.buffer.slice(8))
}
};
// 创建视频解码器
function createVideoDecoder(){
videoDecoder = new VideoDecoder({
output: (videoFrame) => {
createImageBitmap(videoFrame).then((img) => {
console.log('videoFrames.push')
videoFrames.push({
img,
duration: videoFrame.duration,
timestamp: videoFrame.timestamp
});
videoFrame.close();
});
},
error: (err) => {
console.error('videoDecoder错误:', err);
}
});
const config = {
codec: videoTrack.codec,
codedWidth: videoW,
codedHeight: videoH,
description: getExtradata()
};
VideoDecoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
console.log('VideoDecoder.isConfigSupported: supported');
}).catch(()=>{
console.error('VideoDecoder.isConfigSupported: NOT supported');
});
videoDecoder.configure(config);
}
// 创建视频编码器
function createVideoEncoder(){
videoEncoder = new VideoEncoder({
output: (chunk, meta) => {
muxer.addVideoChunk(chunk, meta);
},
error: e => {
console.log('VideoEncoder', e.message);
},
});
const config = {
//codec: videoTrack.codec,
codec: 'vp09.00.10.08',
width: videoW,
height: videoH,
bitrate: 2_000_000, // 2 Mbps
framerate: 20,
};
VideoEncoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
console.log('VideoEncoder.isConfigSupported: supported');
}).catch(()=>{
console.error('VideoEncoder.isConfigSupported: NOT supported');
});
videoEncoder.configure(config);
}
// 创建封装器
function createMuxer(){
// 封装chunk, 新建mp4-muxer
// https://github.com/Vanilagy/webm-muxer?tab=readme-ov-file
// https://vanilagy.github.io/webm-muxer/demo/
muxer = new WebMMuxer.Muxer({
target: new WebMMuxer.ArrayBufferTarget(),
video: {
//codec: videoTrack.codec,
codec: 'V_VP9',
width: videoW,
height: videoH,
},
audio: audioTrack ? {
codec: 'A_OPUS',
sampleRate: audioTrack ? audioTrack.audio.sample_rate : 20000,
numberOfChannels: 1
} : undefined,
fastStart: 'in-memory',
})
}
// https://github.com/gpac/mp4box.js/issues/243
// 在 mp4box.onReady 中初始化视频时长、视音频轨道、视频解码器编码器,开始视频采样
mp4box.onReady = function (info) {
console.log('info', info);
// 视频时长(毫秒(1/1000秒))
//videoDuration = info.duration; // 有问题
videoDuration = info.duration / info.timescale * 1000; // 这才是 最终播放时长
// 记住视频轨道信息,onSamples匹配的时候需要
videoTrack = info.videoTracks[0];
// 获取 audioTrack
audioTrack = info.audioTracks[0];
console.log("audioTrack ",audioTrack) // Todo: 没有音频轨道的兼容处理
if (videoTrack != null) {
mp4box.setExtractionOptions(videoTrack.id, 'video', {
nbSamples: NBSampleMax // 抓取帧数上限
})
}
// 视频的宽度和高度
videoW = videoTrack.track_width;
videoH = videoTrack.track_height;
console.log('videoW: ', videoW, 'videoH: ', videoH);
// 创建视频解码器、编码器(音频解码器、编码器在处理完视频后会创建)
createVideoDecoder();
createVideoEncoder();
// 这里可以初始化封装器了
createMuxer();
nbSampleTotal = videoTrack.nb_samples;
console.log('video nbSampleTotal: ' + nbSampleTotal);
// 开始视频采样
mp4box.start();
};
// 在 mp4box.onSamples 中获取到离散的数据块,交给视、音频解码
mp4box.onSamples = function (trackId, ref, samples) {
// samples其实就是采样数据了
if (videoTrack.id === trackId) {
console.log('mp4box onSamples videoTrack');
mp4box.stop();
countSample += samples.length;
for (const sample of samples) {
const type = sample.is_sync ? 'key' : 'delta';
//debugger
const chunk = new EncodedVideoChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data
});
videoDecoder.decode(chunk);
}
if (countSample === nbSampleTotal) {
videoDecoder.flush();
}
return;
}
if (audioTrack.id === trackId) {
console.log('mp4box onSamples audioTrack');
mp4box.stop();
countSample += samples.length;
for (const sample of samples) {
const type = sample.is_sync ? 'key' : 'delta';
const chunk = new EncodedAudioChunk({
type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data,
offset: sample.offset,
});
audioDecoder.decode(chunk);
}
if (countSample === nbSampleTotal) {
audioDecoder.flush();
}
}
};
} // end function StartDecode
这个是解码功能实现最重要的代码片段,在点击选择视频按钮选好视频后执行。
正式处理视频前,我们先定义一些全局变量:mp4box 文件、视频音频的轨道,解、编码器和帧数组、采样上限(最多的帧数)、视频时长、视频的宽度和高度、视频封装器(muxer)。
全局变量声明完后首先需要创建一个mp4box文件实例,内置的fetch执行后把视频数据流输送给mp4box,mp4box的flush触发回调onReady,在onReady中我们能够得到视频文件的详细信息:播放时长、视频音频轨道和视频宽度高度;然后立即初始化视频解码器、编码器、以及最后合成视频的封装器(封装器对于视频音频的格式设置这里参考了官方demo写死了,实测生成的视频在iPhone、安卓手机上可以播放)。然后在onReady的最后调用mp4box.start()、会触发回调onSamples。
onSamples中mp4box会把视频、音频的数据块传入,这时就需要视频、音频解码器解码了。先注意创建视频解码器(videoDecoder)中的output回调,它会把解码后的帧作为图像数据源传入,此时我们会把它作为bitmap保存到视频帧数组中(后面处理每一帧时需要用到)。
脚本第4部分:对解码后的视频帧的再处理
const canvas = document.getElementById('canvas');
const context = canvas.getContext("2d");
// 下面就是点击按钮,然后执行混合模式层的绘制了
// 以上就是解码过程,下面是将解码的videoFrames应用在canvas上,做特效展示
const button = document.querySelector('#run');
button.addEventListener('click', () => {
if (!videoFrames.length) {
console.error('视频解码尚未完成,请稍等');
return;
}
canvas.width = videoW;
canvas.height = videoH;
// 从videoFrames中取出数据,应用到canvas上
let index = 0;
let startTime = document.timeline.currentTime;
// 绘制方法
const draw = () => {
const { img, timestamp, duration } = videoFrames[index];
let elapsedTime = document.timeline.currentTime - startTime;
console.log(timestamp, duration);
// 清除画布
context.clearRect(0, 0, canvas.width, canvas.height);
// 绘制图片,img是从视频中抓取的背景图
context.drawImage(img, 0, 0, canvas.width, canvas.height);
// 在画布中心绘制覆盖层图片
//context.drawImage(coverImg, (canvas.width - coverImg.width) / 2, (canvas.height - coverImg.height) / 2, coverImg.width, coverImg.height);
// 增加旋转动效测试
context.save();
// 平移到画布中心
context.translate(canvas.width / 2, canvas.height / 2);
context.rotate(elapsedTime * 0.005);
context.drawImage(coverImg, -coverImg.width / 2, -coverImg.height / 2, coverImg.width, coverImg.height);
context.restore();
// 这里特殊处理了一下将开头帧的timestamp设为0
const newFrame = new VideoFrame(canvas, {
//timestamp: index==0 ? 0 : elapsedTime * 1000
timestamp: index==0 ? 0 : index * (videoDuration / videoFrames.length) * 1000
});
// 编码这一帧
videoEncoder.encode(newFrame, { keyFrame: index % 100 == 0 });
// 记得close
newFrame.close();
// 开始下一帧绘制
index++;
console.log(index);
if (index === videoFrames.length) {
// 重新开始
//index = 0;
// 视频编码完成,转处理音频
onVideoDemuxingComplete();
}
else{
// 暂停1帧停留的时间后继续处理下一帧
setTimeout(draw, videoDuration / videoFrames.length);
}
}
draw(); // 开始canvas=>videoEncoder
之前(脚本第3部分)是选择完视频后执行的,其中最后1步mp4box.onSamples已经完成了视频流到帧的解码转换。现在我们有视频帧数组(videoFrames),我们可以利用canvas对视频做特别的处理,这里演示了在视频画面中心增加一个旋转纹理的效果。每一帧背景(视频每帧画面)和覆盖纹理绘制完以后,我们把这一帧canvas合成的画面转成一个VideoFrame;注意timestamp对于第1帧必须是0,后面的帧的时间戳也要正确计算,否则导出的视频长度和播放速度会混乱。完成了VideoFrame的实例化,我们就可以把它交给视频编码器(videoEncoder)编码了。注意我们这里每100帧设置了1个关键帧,这样可以避免错误:每个簇最多为32.768秒;否则我们产出的视频超过32.768秒即会失败。在脚本第3部分中,可以看到videoEncoder的output回调会把编码得到的数据块供给 muxer的addVideoChunk方法、即填充视频流给封装器。
当视频帧都处理完毕、我们关闭视频处理、再开始音频处理。
脚本第5部分:视频处理完了,处理音频
// 创建音频解码器
const setupAudioDecoder = () => {
const config = {
codec: audioTrack.codec,
sampleRate: audioTrack.audio.sample_rate,
numberOfChannels: audioTrack.audio.channel_count,
}
audioDecoder = new AudioDecoder({
output: (audioFrame) => {
console.log('decodedAudioFrames.push');
decodedAudioFrames.push(audioFrame);
// 如果audioFrame都push完了,启动音频encode
if(decodedAudioFrames.length == nbSampleTotal){
setTimeout(()=>{
runAudioEncoding();
}, 50);
}
},
error: (err) => {
console.error('AudioDecoder error : ', err);
},
});
AudioDecoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
console.log('AudioDecoder.isConfigSupported: supported');
}).catch(()=>{
console.error('AudioDecoder.isConfigSupported: NOT supported');
});
audioDecoder.configure(config);
mp4box.setExtractionOptions(audioTrack.id, 'audio', { nbSamples: NBSampleMax });
};
// 创建音频编码器
const setupAudioEncoder = () => {
const config = {
// codec: audioTrack.codec, // AudioEncoder does not support this field
codec: 'opus',
sampleRate: audioTrack.audio.sample_rate,
numberOfChannels: audioTrack.audio.channel_count,
bitrate: audioTrack.bitrate,
}
audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: e => console.error(e)
});
AudioEncoder.isConfigSupported(config).then(()=>{ // 判断解析配置是否支持
console.log('AudioEncoder.isConfigSupported: supported');
}).catch(()=>{
console.error('AudioEncoder.isConfigSupported: NOT supported');
});
audioEncoder.configure(config);
};
// 视频编码完,开始处理音频
async function onVideoDemuxingComplete(){
await videoEncoder?.flush();
nbSampleTotal = audioTrack.nb_samples;
console.log('audio nbSampleTotal: ' + nbSampleTotal);
countSample = 0;
setupAudioDecoder();
setupAudioEncoder();
mp4box.start();
}
// 音频解码完,编码音频,编码完成后合成video
async function runAudioEncoding(){
console.log('start Audio Encoding');
for(var i = 0; i < decodedAudioFrames.length; i++){
let audioFrame = decodedAudioFrames[i];
audioEncoder.encode(audioFrame, { keyFrame: i % 100 == 0 });
audioFrame.close();
}
await audioEncoder?.flush();
// 合成video
videoAudioProcessFinal();
}
音频解码、编码器的作用和使用方法基本和视频解编码器相同,这里也没有对音频的再处理。所以处理音频即简单地创建音频解码、编码器后开启mp4box的音频采样(会触发onSamples回调),然后把解析后的音频帧储存到数组、再交给音频编码器、和视频编码类似地audioEncoder的output中使用封装器addAudioChunk方法、编码后音频数据填入封装器。
每一个音频帧处理完,可以进入最后一步:合成video了。
脚本第6部分:最后一步,视频、音频处理完了,合成video
function videoAudioProcessFinal(){
console.log('视频、音频已处理完,合成video');
// 编码完成后
muxer.finalize();
const { buffer } = muxer.target;
// 视频数据转Blob
const blob = new Blob([buffer], { type: 'video/mp4' });
// 设置预览视频
var video = document.getElementById('result_view');
var source = document.getElementById('videoSource');
let previewVideoPath = URL.createObjectURL( blob );
source.src = previewVideoPath;
video.load();
// 将这个视频下载到本地
const a = document.createElement('a');
a.download = 'test' + '.mp4';
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// 按钮禁用
button.disabled = true;
}); // 点击编码按钮回调结束
最后一步,执行muxer.finalize()完结处理,把视频数据从muxer的target中取出,使用blob和URL.createObjectURL可以得到临时结果视频路径,然后就可以传到页面video显示、并下载。
脚本第4、5、6部分都是在“重编码MP4”按钮点击事件中执行的。脚本第1至6部分是连续的,读者可以不用增删代码把6个部分拼接在一起即获得(html内script的)完整的代码。
代码中打印了各个阶段执行的日志信息,可以打开Console查看执行情况。
至此,本文完成了基于Webcodecs、mp4box和WebMMuxer的前端视频加贴图效果的完整实现流程的说明。在此基础上、还可以实现很多其他效果,比如视频剪辑、MP4转Gif等。最后希望本文对读者有一定的帮助。