最近公司想要举办一个粉丝群活动,需要实现在手机端给自拍视频增加相框、装饰的效果、并能保存下来,而且需要保留视频中的声音。
首先参考的是微信小程序中提供的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等。最后希望本文对读者有一定的帮助。
发表评论