120 行代碼實現(xiàn)純 Web 剪輯視頻
本文轉載自微信公眾號「微醫(yī)大前端技術」,作者翁佳瑞 。轉載本文請聯(lián)系微醫(yī)大前端技術公眾號。
前言
前幾天偶爾看到一篇 webassembly 的相關文章,對這個技術還是挺感興趣的,在了解一些相關知識的基礎上,看下自己能否小小的實踐下。
什么是 webasembly?
WebAssembly(wasm)就是一個可移植、體積小、加載快并且兼容 Web 的全新格式。可以將 C,C++等語言編寫的模塊通過編譯器來創(chuàng)建 wasm 格式的文件,此模塊通過二進制的方式發(fā)給瀏覽器,然后 js 可以通過 wasm 調用其中的方法功能。
WebAssembly 的優(yōu)勢
網(wǎng)上對于這個相關的介紹應該有很多了,WebAssembly 優(yōu)勢性能好,運行速度遠高于 Js,對于需要高計算量、對性能要求高的應用場景如圖像/視頻解碼、圖像處理、3D/WebVR/AR 等,優(yōu)勢非常明顯,們可以將現(xiàn)有的用 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))
- });
這時我們即可在控制臺看到對應的打印日志,成功調用我們編譯的代碼啦
正式開動
既然我們已經(jīng)知道如何能快速的調用到一些已經(jīng)成熟的 C,C++的類庫,那我們離在線剪輯視頻預期目標更進一步了。
最終 demo 演示
由于錄制操作的電腦 cpu 不太行,所以可能耗時比較久,但整體的效果還是能看的到滴
demo 倉庫地址(https://github.com/Dseekers/clip-video-by-webassembly)
FFmpeg
在這個之前你得稍微的了解下啥是 FFmpeg? 以下根據(jù)維基百科的目錄解釋:
FFmpeg 是一個開放源代碼的自由軟件,可以運行音頻和視頻多種格式的錄影、轉換、流功能[1],包含了 libavcodec——這是一個用于多個項目中音頻和視頻的解碼器庫,以及 libavformat——一個音頻與視頻格式轉換庫。
簡單的說這個就是由 C 語言編寫的視頻處理軟件,它的用法也是相當?shù)魏唵?/p>
我主要將這次需要用到的命令給調了出來,如果你還可能用到別的命令,可以根據(jù)他的官方文檔查看 ,還可以了解下阮一峰大佬的文章 (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 存在較多的環(huán)境問題,所以我們這次直接使用在線已經(jīng)編譯好的 CDN 資源
這邊就直接使用了這個比較成熟的庫 https://github.com/ffmpegwasm/ffmpeg.wasm
為了本地調試方便,我把其相關的資源都下了下來 一共 4 個資源文件
- ffmpeg.min.js
- ffmpeg-core.js
- ffmpeg-core.wasm
- ffmpeg-core.worker.js
我們使用的時候只需引入第一個文件即可,其它文件會在調用時通過 fetch 方式去拉取資源
最小的功能實現(xiàn)
前置功能實現(xiàn): 在我們本地需要實現(xiàn)一個 node 服務,因為使用 ffmpeg 這個模塊會出現(xiàn)如果沒在服務器端設置響應頭, 會報錯 SharedArrayBuffer is not defined,這個是因為系統(tǒng)的安全漏洞,瀏覽器默認禁用了該 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 來實現(xiàn)下這個剪輯功能,剪輯視頻的前一秒鐘 新建一個 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 去創(chuàng)建一個實例,然后通過 ffmpeg.load()方法去加載對應的 wasm 和 worker 資源 沒有進行優(yōu)化的 wasm 的資源是相當?shù)未?,本地文件竟?23MB,這個若是需要投入生產(chǎn)的可是必須通過 emcc 調節(jié)打包參數(shù)的方式去掉無用模塊。然后通 fetchFile 方法將選中的 input file 加載到內存中去,接下來就可以通過 ffmpeg.run 運行和 本地命令行一樣的 ffmpeg 命令行參數(shù)了參數(shù)基本一致。
這時我們的核心功能已經(jīng)實現(xiàn)完畢了。
做一點小小的優(yōu)化
剪輯的話最好是可以選擇時間段,我這為了方便直接把 element 的以 cdn 方式引入使用 通過 slider 來截取視頻區(qū)間,我這邊就只貼 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()
- });
這樣文章開頭的效果就這樣實現(xiàn)啦
小結
webassbembly 還是比較新的一項技術,我這邊只是應用了其中一小部分功能,值得我們探索的地方還有很多,歡迎大家多多交流哈
參考資料
WebAssembly 完全入門——了解 wasm 的前世今生
(https://juejin.cn/post/6844903709806182413)
使用 FFmpeg 與 WebAssembly 實現(xiàn)純前端視頻截幀 (https://toutiao.io/posts/7as4kva/preview)
前端視頻幀提取 ffmpeg + Webassembly (https://juejin.cn/post/6854573219454844935)