120 行代码实现纯 Web 剪辑视频
翁佳瑞:微医前端技术部前端工程师,一个爱玩 dota2 的咸鱼。
前言
前几天偶尔看到一篇 webassembly 的相关文章,对这个技术还是挺感兴趣的,在了解一些相关知识的基础上,看下自己能否小小的实践下。
什么是 webasembly?
WebAssembly(wasm)就是一个可移植、体积小、加载快并且兼容 Web 的全新格式。可以将 C,C++等语言编写的模块通过编译器来创建 wasm 格式的文件,此模块通过二进制的方式发给浏览器,然后 js 可以通过 wasm 调用其中的方法功能。
WebAssembly 的优势
网上对于这个相关的介绍应该有很多了,WebAssembly 优势性能好,运行速度远高于 Js,对于需要高计算量、对性能要求高的应用场景如图像/视频解码、图像处理、3D/WebVR/AR 等,优势非常明显,们可以将现有的用 C、C++等语言编写的库直接编译成 WebAssembly 运行到浏览器上,并且可以作为库被 JavaScript 引用。那就意味着我们可以将很多后端的工作转移到前端,减轻服务器的压力。.........
WebAssembly 最简单的实践调用
我们编写一个最简单的 c 文件
int add(int a,int b) {
return a + b;
}
然后安装对于的 Emscripten 编译器Emscripten 安装指南
emcc test.c -Os -s WASM=1 -s SIDE_MODULE=1 -o test.wasm
然后我们在 html 中引入使用即可
fetch('./test.wasm').then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes)
).then(results => {
const add = results.instance.exports.add
console.log(add(11,33))
});
这时我们即可在控制台看到对应的打印日志,成功调用我们编译的代码啦
正式开动
既然我们已经知道如何能快速的调用到一些已经成熟的 C,C++的类库,那我们离在线剪辑视频预期目标更进一步了。
最终 demo 演示
由于录制操作的电脑 cpu 不太行,所以可能耗时比较久,但整体的效果还是能看的到滴
demo 仓库地址(https://github.com/Dseekers/clip-video-by-webassembly)
FFmpeg
在这个之前你得稍微的了解下啥是 FFmpeg? 以下根据维基百科的目录解释
FFmpeg 是一个开放源代码的自由软件,可以运行音频和视频多种格式的录影、转换、流功能[1],包含了 libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat——一个音频与视频格式转换库。
简单的说这个就是由 C 语言编写的视频处理软件,它的用法也是相当滴简单
我主要将这次需要用到的命令给调了出来,如果你还可能用到别的命令,可以根据他的官方文档查看 ,还可以了解下阮一峰大佬的文章 (https://www.ruanyifeng.com/blog/2020/01/ffmpeg.html)
ffmpeg -ss [start] -i [input] -to [end] -c copy [output]
start 为开始时间 end 为结束时间 input 为需要操作的视频源文件 output 为输出文件的位置名称
这一行代码就是我们需要用到的剪辑视频的命令了
获取相关的FFmpeg的wasm
由于通过 Emscripten 编译 ffmpeg 成 wasm 存在较多的环境问题,所以我们这次直接使用在线已经编译好的 CDN 资源
这边就直接使用了这个比较成熟的库 https://github.com/ffmpegwasm/ffmpeg.wasm
为了本地调试方便,我把其相关的资源都下了下来 一共 4 个资源文件
ffmpeg.min.js
ffmpeg-core.js
ffmpeg-core.wasm
ffmpeg-core.worker.js
我们使用的时候只需引入第一个文件即可,其它文件会在调用时通过 fetch 方式去拉取资源
最小的功能实现
前置功能实现:
在我们本地需要实现一个 node 服务,因为使用 ffmpeg 这个模块会出现如果没在服务器端设置响应头,
会报错 SharedArrayBuffer is not defined
,这个是因为系统的安全漏洞,浏览器默认禁用了该 api,若要启用则需要在 header 头上设置
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
我们启动一个简易的 node 服务
const Koa = require('koa');
const path = require('path')
const fs = require('fs')
const router = require('koa-router')();
const static = require('koa-static')
const staticPath = './static'
const app = new Koa();
app.use(static(
path.join(__dirname, staticPath)
))
// log request URL:
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
ctx.set('Cross-Origin-Opener-Policy', 'same-origin')
ctx.set('Cross-Origin-Embedder-Policy', 'require-corp')
await next();
});
router.get('/', async (ctx, next) => {
ctx.response.body = '<h1>Index</h1>';
});
router.get('/:filename', async (ctx, next) => {
console.log(ctx.request.url)
const filePath = path.join(__dirname, ctx.request.url);
console.log(filePath)
const htmlContent = fs.readFileSync(filePath);
ctx.type = "html";
ctx.body = htmlContent;
});
app.use(router.routes());
app.listen(3000);
console.log('app started at port 3000...');
我们做一个最小化的 demo 来实现下这个剪辑功能,剪辑视频的前一秒钟 新建一个 demo.html 文件,引入相关资源
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<script src="./assets/ffmpeg.min.js"></script>
<div class="container">
<div class="operate">
选择原始视频文件:
<input type="file" id="select_origin_file">
<button id="start_clip">开始剪辑视频</button>
</div>
<div class="video-container">
<div class="label">原视频</div>
<video class="my-video" id="origin-video" controls></video>
</div>
<div class="video-container">
<div class="label">处理后的视频</div>
<video class="my-video" id="handle-video" controls></video>
</div>
</div>
let originFile
$(document).ready(function () {
$('#select_origin_file').on('change', (e) => {
const file = e.target.files[0]
originFile = file
const url = window.webkitURL.createObjectURL(file)
$('#origin-video').attr('src', url)
})
$('#start_clip').on('click', async function () {
const { fetchFile, createFFmpeg } = FFmpeg;
ffmpeg = createFFmpeg({
log: true,
corePath: './assets/ffmpeg-core.js',
});
const file = originFile
const { name } = file;
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
ffmpeg.FS('writeFile', name, await fetchFile(file));
await ffmpeg.run('-i', name, '-ss', '00:00:00', '-to', '00:00:01', 'output.mp4');
const data = ffmpeg.FS('readFile', 'output.mp4');
const tempURL = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
$('#handle-video').attr('src', tempURL)
})
});
其代码的含义也是相当简单,通过引入的 FFmpeg 去创建一个实例,然后通过 ffmpeg.load()方法去加载对应的 wasm 和 worker 资源 没有进行优化的 wasm 的资源是相当滴大,本地文件竟有 23MB,这个若是需要投入生产的可是必须通过 emcc 调节打包参数的方式去掉无用模块。然后通 fetchFile 方法将选中的 input file 加载到内存中去,接下来就可以通过 ffmpeg.run 运行和 本地命令行一样的 ffmpeg 命令行参数了参数基本一致
这时我们的核心功能已经实现完毕了。
做一点小小的优化
剪辑的话最好是可以选择时间段,我这为了方便直接把 element 的以 cdn 方式引入使用 通过 slider 来截取视频区间,我这边就只贴 js 相关的代码了,具体代码可以去 github 仓库里面仔细看下
class ClipVideo {
constructor() {
this.ffmpeg = null
this.originFile = null
this.handleFile = null
this.vueInstance = null
this.currentSliderValue = [0, 0]
this.init()
}
init() {
console.log('init')
this.initFfmpeg()
this.bindSelectOriginFile()
this.bindOriginVideoLoad()
this.bindClipBtn()
this.initVueSlider()
}
initVueSlider(maxSliderValue = 100) {
console.log(`maxSliderValue ${maxSliderValue}`)
if (!this.vueInstance) {
const _this = this
const Main = {
data() {
return {
value: [0, 0],
maxSliderValue: maxSliderValue
}
},
watch: {
value() {
_this.currentSliderValue = this.value
}
},
methods: {
formatTooltip(val) {
return _this.transformSecondToVideoFormat(val);
}
}
}
const Ctor = Vue.extend(Main)
this.vueInstance = new Ctor().$mount('#app')
} else {
this.vueInstance.maxSliderValue = maxSliderValue
this.vueInstance.value = [0, 0]
}
}
transformSecondToVideoFormat(value = 0) {
const totalSecond = Number(value)
let hours = Math.floor(totalSecond / (60 * 60))
let minutes = Math.floor(totalSecond / 60) % 60
let second = totalSecond % 60
let hoursText = ''
let minutesText = ''
let secondText = ''
if (hours < 10) {
hoursText = `0${hours}`
} else {
hoursText = `${hours}`
}
if (minutes < 10) {
minutesText = `0${minutes}`
} else {
minutesText = `${minutes}`
}
if (second < 10) {
secondText = `0${second}`
} else {
secondText = `${second}`
}
return `${hoursText}:${minutesText}:${secondText}`
}
initFfmpeg() {
const { createFFmpeg } = FFmpeg;
this.ffmpeg = createFFmpeg({
log: true,
corePath: './assets/ffmpeg-core.js',
});
}
bindSelectOriginFile() {
$('#select_origin_file').on('change', (e) => {
const file = e.target.files[0]
this.originFile = file
const url = window.webkitURL.createObjectURL(file)
$('#origin-video').attr('src', url)
})
}
bindOriginVideoLoad() {
$('#origin-video').on('loadedmetadata', (e) => {
const duration = Math.floor(e.target.duration)
this.initVueSlider(duration)
})
}
bindClipBtn() {
$('#start_clip').on('click', () => {
console.log('start clip')
this.clipFile(this.originFile)
})
}
async clipFile(file) {
const { ffmpeg, currentSliderValue } = this
const { fetchFile } = FFmpeg;
const { name } = file;
const startTime = this.transformSecondToVideoFormat(currentSliderValue[0])
const endTime = this.transformSecondToVideoFormat(currentSliderValue[1])
console.log('clipRange', startTime, endTime)
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
ffmpeg.FS('writeFile', name, await fetchFile(file));
await ffmpeg.run('-i', name, '-ss', startTime, '-to', endTime, 'output.mp4');
const data = ffmpeg.FS('readFile', 'output.mp4');
const tempURL = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));
$('#handle-video').attr('src', tempURL)
}
}
$(document).ready(function () {
const instance = new ClipVideo()
});
这样文章开头的效果就这样实现啦
小结
webassbembly 还是比较新的一项技术,我这边只是应用了其中一小部分功能,值得我们探索的地方还有很多,欢迎大家多多交流哈
参考资料
WebAssembly 完全入门——了解 wasm 的前世今生 (https://juejin.cn/post/6844903709806182413) 使用 FFmpeg 与 WebAssembly 实现纯前端视频截帧 (https://toutiao.io/posts/7as4kva/preview) 前端视频帧提取 ffmpeg + Webassembly (https://juejin.cn/post/6854573219454844935)
往期推荐
Vite 太快了,烦死了,是时候该小睡一会了。
如何实现比 setTimeout 快 80 倍的定时器?
万字长文!总结Vue 性能优化方式及原理
90 行代码的 webpack,你确定不学吗?
如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:
点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)
欢迎加我微信「huab119」拉你进技术群,长期交流学习...
关注公众号「前端劝退师」,持续为你推送精选好文,也可以加我为好友,随时聊骚。