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 仓库地址

demo.gif

FFmpeg

在这个之前你得稍微的了解下啥是 FFmpeg? 以下根据维基百科的目录解释

FFmpeg 是一个开放源代码的自由软件,可以运行音频和视频多种格式的录影、转换、流功能[1],包含了 libavcodec——这是一个用于多个项目中音频和视频的解码器库,以及 libavformat——一个音频与视频格式转换库。

简单的说这个就是由 C 语言编写的视频处理软件,它的用法也是相当滴简单

我主要将这次需要用到的命令给调了出来,如果你还可能用到别的命令,可以根据他的官方文档查看 ,还可以了解下阮一峰大佬的文章

ffmpeg -ss [start] -i [input] -to [end] -c copy [output]
复制代码

start 为开始时间 end 为结束时间 input 为需要操作的视频源文件 output 为输出文件的位置名称

这一行代码就是我们需要用到的剪辑视频的命令了

获取相关的 FFmpeg 的 wasm

由于通过 Emscripten 编译 ffmpeg 成 wasm 存在较多的环境问题,所以我们这次直接使用在线已经编译好的 CDN 资源

这边就直接使用了这个比较成熟的库 github.com/ffmpegwasm/…

为了本地调试方便,我把其相关的资源都下了下来 一共 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 还是比较新的一项技术,我这边只是应用了其中一小部分功能,值得我们探索的地方还有很多,欢迎大家多多交流哈

参考资料

前往微医互联网医院在线诊疗平台,快速问诊,3分钟为你找到三甲医生。

副本_副本_未命名_自定义px_2021-09-08-0.gif

文章分类
前端
文章标签