做者:Colin Eberhardt
翻译:疯狂的技术宅
原文: https://blog.scottlogic.com/2...
咱们能够凭借 FFmpeg 的 WebAssembly 版直接在浏览器中运行这个能强大的视频处理工具。在本文中,咱们来探索一下 FFmpeg.wasm,并写一个简单的代码转换器,把数据流传输到视频元素中并播放出来。javascript
通常咱们经过其命令行使用 FFmpeg。例以下面的命令能够吧 AVI 文件转码为 MP4 格式:html
$ ffmpeg -i input.avi output.mp4
一样的工做也能够在浏览器中作到。FFmpeg.wasm 是 FFmpeg 的 WebAssembly 端口,像其余 JavaScript 模块同样能够经过 npm 安装,并在 Node 或浏览器中使用:前端
$ npm install @ffmpeg/ffmpeg @ffmpeg/core
装好 FFmpeg.wasm 后,能够在浏览器中执行等效的转码操做:java
// fetch AVI 文件 const sourceBuffer = await fetch("input.avi").then(r => r.arrayBuffer()); // 建立 FFmpeg 实例并载入 const ffmpeg = createFFmpeg({ log: true }); await ffmpeg.load(); // 把 AVI 写入 FFmpeg 文件系统 ffmpeg.FS( "writeFile", "input.avi", new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength) ); // 执行 FFmpeg 命令行工具, 把 AVI 转码为 MP4 await ffmpeg.run("-i", "input.avi", "output.mp4"); // 把 MP4 文件从 FFmpeg 文件系统中取出 const output = ffmpeg.FS("readFile", "output.mp4"); // 对视频文件进行后续操做 const video = document.getElementById("video"); video.src = URL.createObjectURL( new Blob([output.buffer], { type: "video/mp4" }) );
这里有不少有趣的东西,接下来深刻研究细节。git
在 fetch API 加载 AVI 文件以后,用下面的步骤初始化 FFmpeg:程序员
const ffmpeg = createFFmpeg({ log: true }); await ffmpeg.load();
FFmpeg.wasm 由一个很薄的 JavaScript API 层和一个较大的(20M)WebAssembly 二进制文件组成。上面的代码加载并初始化了可供使用的 WebAssembly 文件。github
WebAssembly 是在浏览器中运行的、通过性能优化的底层字节码。它被专门设计为可以用多种语言进行开发和编译。web
FFmpeg 的历史已经超过20年了,有一千多人贡献过代码。在 WebAssembly 出现以前,要给它建立 JavaScript 可以调用的接口,所涉及的工做可能会很是繁琐。面试
将来 WebAssembly 的使用会更加普遍,如今它做为把大量成熟的 C/C++ 代码库引入 Web 的一种机制,已经很是成功了, Google Earth,AutoCAD 和 TensorFlow 等都是很是典型的案例。docker
在初始化以后,下一步是把 AVI 文件写入文件系统:
ffmpeg.FS( "writeFile", "input.avi", new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength) );
这段代码有些奇怪,想要知道这是什么状况,须要更深刻地研究 FFmpeg.wasm 的编译方式。
Emscripten 是遵循 WebAssembly 规范开发的把 C/C++ 代码编译为 WebAssembly 的工具链,正是它把 FFmpeg.wasm 编译为 WebAssembly 的。可是 Emscripten 不仅是一个 C++ 编译器,为了简化现有代码库的迁移,它经过基于 Web 的等效项提供对许多 C/C++ API 的支持。例如经过把函数调用映射到 WebGL 来支持 OpenGL。它还支持 SDL、POSIX 和 pthread。
Emscripten 经过提供 file-system API 来映射到内存中的存储。使用 FFmpeg.wasm 能够直接经过 ffmpeg.FS 函数公开底层的 Emscripten 文件系统API,你能够用这个借口浏览目录、建立文件和其余各类针对文件系统的操做。
下一步是真正有意思的地方:
await ffmpeg.run("-i", "input.avi", "output.mp4");
若是你在 Chrome 的开发工具中进行观察,会注意到它建立了许多 Web Worker,每一个 Web Worker 都加载了 ffmpeg.wasm:
在这里用到了 Emscripten 的 Pthread 支持(https://emscripten.org/docs/p...)。启用日志记录后,你能够在控制台中查看进度;
Output #0, mp4, to 'output.mp4': Metadata: encoder : Lavf58.45.100 Stream #0:0: Video: h264 (libx264) (avc1 / 0x31637661), yuv420p, 256x240, q=-1--1, 35 fps, 17920 tbn, 35 tbc Metadata: encoder : Lavc58.91.100 libx264 Side data: cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A frame= 47 fps=0.0 q=0.0 size= 0kB time=00:00:00.00 bitrate=N/A speed= 0x frame= 76 fps= 68 q=30.0 size= 0kB time=00:00:00.65 bitrate= 0.6kbits/s speed=0.589x frame= 102 fps= 62 q=30.0 size= 0kB time=00:00:01.40 bitrate= 0.3kbits/s speed=0.846x
最后一步是读取输出文件并将其提供给 video 元素:
const output = ffmpeg.FS("readFile", "output.mp4"); const video = document.getElementById("video"); video.src = URL.createObjectURL( new Blob([output.buffer], { type: "video/mp4" }) );
有趣的是,带有虚拟文件系统的命令行工具 FFmpeg.wasm 有点像 docker!
对大视频文件进行编码转换可能耗时较长。咱们能够先把文件转码为切片,并将其逐步添加到视频缓冲区中。
你能够用 Media Source Extension APIs 来构建流媒体播放,其中包括 MediaSource
和 SourceBuffer
对象。建立和加载缓冲区的操做可能很是棘手,由于这两个对象都提供了生命周期事件,必须经过处理这些事件才能在正确的时间添加新的缓冲区。为了管理这些事件的协调,我用到了 RxJS。
下面的函数基于 FFmpeg.wasm 转码后的输出建立一个 RxJS Observable:
const bufferStream = filename => new Observable(async subscriber => { const ffmpeg = FFmpeg.createFFmpeg({ corePath: "thirdparty/ffmpeg-core.js", log: false }); const fileExists = file => ffmpeg.FS("readdir", "/").includes(file); const readFile = file => ffmpeg.FS("readFile", file); await ffmpeg.load(); const sourceBuffer = await fetch(filename).then(r => r.arrayBuffer()); ffmpeg.FS( "writeFile", "input.mp4", new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength) ); let index = 0; ffmpeg .run( "-i", "input.mp4", // 给流进行编码 "-segment_format_options", "movflags=frag_keyframe+empty_moov+default_base_moof", // 编码为 5 秒钟的片断 "-segment_time", "5", // 经过索引写入文件系统 "-f", "segment", "%d.mp4" ) .then(() => { // 发送剩余的文件内容 while (fileExists(`${index}.mp4`)) { subscriber.next(readFile(`${index}.mp4`)); index++; } subscriber.complete(); }); setInterval(() => { // 按期检查是否已写入文件 if (fileExists(`${index + 1}.mp4`)) { subscriber.next(readFile(`${index}.mp4`)); index++; } }, 200); });
上面的代码用了和之前相同的 FFmpeg.wasm 设置,将要转码的文件写入内存文件系统。为了建立分段输出,ffmpeg.run
的配置与上一个例子有所不一样,须要设置合适的转码器。在运行时 FFmpeg 把带有增量索引(0.mp4
, 1.mp4
, …)的文件写入内存文件系统。
为了实现流式传输输出,须要经过轮询文件系统以获取转码后的输出,并经过 subscriber.next
把数据做为事件发出。最后当 ffmpeg.run
完成时,余下的文件内容被送出并关闭流。
须要建立一个MediaSource
对象来把数据流传输到视频元素中,并等待 sourceopen
事件触发。下面的代码用到了 RxJS 的 combineLatest
来确保在触发这个事件以前不处理 FFmpeg 输出:
const mediaSource = new MediaSource(); videoPlayer.src = URL.createObjectURL(mediaSource); videoPlayer.play(); const mediaSourceOpen = fromEvent(mediaSource, "sourceopen"); const bufferStreamReady = combineLatest( mediaSourceOpen, bufferStream("4club-JTV-i63.avi") ).pipe(map(([, a]) => a));
当接收到第一个视频切片或缓冲时,须要在正确的时间向 SourceBuffer
添加 MediaSource
,并将原始缓冲区附加到 SourceBuffer
。在此以后,还有一个须要注意的地方,新缓冲不能立刻添加到 SourceBuffer
中,须要等到它发出 updateend
事件代表先前的缓冲区已被处理后才行。
下面的代码用 take
处理第一个缓冲区,并用 mux.js 库读取 mime 类型。而后从 updateend
事件返回一个新的可观察流:
const sourceBufferUpdateEnd = bufferStreamReady.pipe( take(1), map(buffer => { // 基于当前的 mime type 建立一个buffer const mime = `video/mp4; codecs="${muxjs.mp4.probe .tracks(buffer) .map(t => t.codec) .join(",")}"`; const sourceBuf = mediaSource.addSourceBuffer(mime); // 追加道缓冲区 mediaSource.duration = 5; sourceBuf.timestampOffset = 0; sourceBuf.appendBuffer(buffer); // 建立一个新的事件流 return fromEvent(sourceBuf, "updateend").pipe(map(() => sourceBuf)); }), flatMap(value => value) );
剩下的就是在缓冲区到达及 SourceBuffer
准备就绪时追加缓冲区。能够经过 RxJS 的 zip 函数实现:
zip(sourceBufferUpdateEnd, bufferStreamReady.pipe(skip(1))) .pipe( map(([sourceBuf, buffer], index) => { mediaSource.duration = 10 + index * 5; sourceBuf.timestampOffset = 5 + index * 5; sourceBuf.appendBuffer(buffer.buffer); }) ) .subscribe();
就这样对事件进行了一些协调,最终只需不多的代码就能对视频进行转码了,并将结果逐渐添加到视频元素中。
最后一个例子的代码在GitHub上。