查看原文
其他

Cutter - Web视频剪辑工具原理浅析

ELab.zhugetao ELab团队 2022-07-30

最近一直在开发 web视频剪辑工具(cutter),这个工具可以方便老师们编辑拍摄好的视频。

这是一个挺有意思的项目,预计分多章和大家分享介绍。

本期主要介绍下其大体流程,方便大家对其原理有一个简单认知

Cutter 剪辑工具效果演示:

web编辑器


导出效果

阅读完本文,预计可以收获的知识点:

  • 了解web视频剪辑工具 基本原理
  • 实战一个ffmpeg+wasm+offscreen canvas demo

技术链路

全链路可以简单分为

  • 页面依赖的底层:vesdk
  • 页面核心交互:web剪辑工具
  • 后端:视频架构-视频合成

问题抛出

为了更好的理解全链路,这里我们抛出两个问题,带着问题来看整体链路,增强我们的理解:

Q1:视频是怎么在网页端实现编辑预览的效果?

Q2:怎么保证预览效果和合成效果一致性?

Q1:视频是怎么在网页端实现编辑预览的效果

目前web视频编辑主要有两个方向

  • 一种是使用原生JS,基于浏览器提供的

  • 多媒体前端技术入门指南 - TeqNG[1]
  • 腾讯云剪 - web多媒体技术在视频编辑场景的应用[2]
  • 爱奇艺云剪辑Web端的技术实现[3]
  • 另一种是直接使用WebAssembly将现有基于C/C++等代码的视频编辑框架编译到浏览器上运行
  • 《VESDK技术演进之Web音视频编辑技术》

二者的对比,可以参考如下:

图片来源 《VESDK技术演进之Web音视频编辑技术》

流程图

vesdk采用的是第二种方式(ffmpeg+wasm),大体流程转换图可参考如下:


调度逻辑:

解码、绘制时尽力出帧frame,将frame放入缓存池中,上屏时结合raf 自行根据fps计算下一帧渲染时间点

剪辑中 音频、文字 是怎么绘制的:

音频:在主线程中,基于Web Audio的OpenAL API来构建

文字、特效:基于webgl shader等直接绘制

为了更好的理解上面的流程图,介绍下里面的一些关键名词

YUV

YUV是一种颜色编码格式,能够通过公式计算还原为RGB,相比于RGB编码体积占用更小。

R = Y + 1.140*V

G = Y - 0.394U - 0.581V

B = Y + 2.032*U

“Y”表示明亮度(Luminance或Luma),也就是灰阶值

“U”和“V”表示的则是色度(Chrominance或Chroma),作用是描述影像色彩及饱和度,用于指定像素的颜色

例如:通过YUV预览工具,我们可以看到各个分量单独显示时的成像

FFmpeg

  • 媒体内容源文件都是比较大的,为了便于传输和存储,需要对原视频通过编码来压缩,再通过容器封装将压缩后的视频、音频、字幕等组合到一个容器内,这就是 编码容器封装 的过程(例如可以用 压缩饼干封袋包装 来理解,会出现很多不同的压缩工艺和包装规格)
  • 在播放端进行播放时,进行相应的 解封装解码,得到原文件数据

在上述过程中,FFmpeg就是这样一款领先的多媒体框架,几乎实现了当下所有常见的数据封装格式、多媒体传输协议、音视频编解码器。

FFmpeg提供了两种调用姿势,可以面向不同场景需求:

  • 调用方法一:应用层可以调用 ffmpeg[4] \ffprobe等命令行 cli 工具 来读写媒体文件;
// 例如:
ffmpeg -i tempalte.mp4 -pix_fmt yuv420p tempalte.yuv

可以看到解封装后,原文件体积是远大于1.1MB的

  • 调用方法二:c层面可以调用 FFmpeg 下层编解码器等外部库用来实现编解码,获取原生图像和音频数据
// 可以参考该文章Mp4读取yuv数据
https://www.jianshu.com/p/f4516e6df9f1
// 几个关键api
av_read_frame    读取视频流的h264帧数据
avcodec_send_packet     将h264帧数据发送给解码器
avcodec_receive_frame    从解码器中读出解码后的yuv数据

// demo
void decode() {
    char *path = "/template.mp4";
    ...
    while(true) {
        av_read_frame(avformat_context, packet);//读取文件中的h264帧
        if (packet->stream_index == videoStream) {
            int ret = avcodec_send_packet(avcodec_context, packet);//将h264帧发送到解码器
            if (ret < 0) {
                break;
            }
            while (true) {
                int ret = avcodec_receive_frame(avcodec_context, frame);//从解码器获取帧
                sws_scale(sws_context,
                            (uint8_t const * const *) frame->data,
                            frame->linesize, 0, avcodec_context->height, pFrameYUV->data,
                            pFrameYUV->linesize);//将帧数据转为yuv420p
                fwrite(pFrameYUV->data[0], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height, pFile);//将y数据写入文件中
                fwrite(pFrameYUV->data[1], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height / 4, pFile);//将u数据写入文件中
                fwrite(pFrameYUV->data[2], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height / 4, pFile);//将v数据写入文件中
            }
        }
    }
    ...
}

WASM

WebAssembly是一种安全、可移植、效率高、文件小的格式,其提供的命令行工具wasm可以将高级语言(如 C++)编写的代码转换为浏览器可理解的机器码,所以实现了在浏览器中直接运行。

例如c代码 经过如下步骤,可以被浏览器直接执行

加法 demo小例子:

step1、可利用在线工具(https://mbebenita.github.io/WasmExplorer/)编写一个加法例子,然后下载得到 编译好的 test.wasm 文件

step2、加载test.wasm

step3、浏览器中直接运行

OffscreenCanvas

OffscreenCanvas[5]非常有意思,这是一个离前端开发人员比较近的概念,它是一个可以脱离屏幕渲染的canvas对象,在主线程环境和web worker环境均有效。

OffscreenCanvas 一般搭配worker使用,目前主要用于两种不同的使用场景:

image.png

流程优点劣势
模式一:同步显示offscrrenCanvas中的帧step1、在 Worker 线程创建一个 OffscreenCanvas 做后台渲染step2、再把渲染好的缓冲区 Transfer 回主线程显示主线程可以直接控制渲染内容canvas渲染受主线程影响
模式二:异步显示offscrrenCanvas中的帧step1、将主线程中 Canvas 转换为 OffscreenCanvas,并发送给worker线程step2、worker线程获取到OffscreenCanvas后,进行绘制计算操作,最后把绘制结果直接 Commit 到浏览器的 Display Compositor (相当于在 Worker 线程直接更新 Canvas 元素的内容,不走常规的渲染流程)(参考表格下面的图)canvas渲染不受主线程影响-   避免绘制过程中的大量的计算阻塞主线程-   避免主线程的耗时任务阻塞渲染主线程无法控制绘制内容

模式一:

 // 主线程 进行渲染  
const ctx = renderCanvas.getContext( '2d' ); 
const worker = new Worker( 'worker.js' ); 
worker.onmessage = function ( msg ) {  
    if (msg.data.method === 'transfer' ) { 
    ctx.drawImage(msg.data.buffer, 0 , 0 ); 
  } 
};   

    // worker线程
onmessage = async (event) => {     
    const offscreenCanvas = new OffscreenCanvas( 480 , 270 );    
    const ctx = offscreenCanvas.getContext( "2d" );    
    // ctx绘制工作     
    ...    
    const imageBitmap = await self.createImageBitmap( new Blob([data.buffer]));    
    ctx.drawImage(imageBitmap, 0 , 0 );
    let imageBitmap = offscreenCanvas.transferToImageBitmap();
    // bitmap发送给主线程     
    postMessage({ method : "transfer" , buffer : imageBitmap}, [imageBitmap])
}

备注:postMessage 常规传递是通过拷贝的方式;对此postMessage提供了第二个参数,可以传入实现了Transferable[6]接口的数据(例如 ImageBitmap),这些数据的控制权会被转移到子线程,转移后主线程无法使用这些数据(会抛错)

备注:postMessage 常规传递是通过拷贝的方式;对此postMessage提供了第二个参数,可以传入实现了Transferable[7]接口的数据(例如 ImageBitmap),这些数据的控制权会被转移到子线程,转移后主线程无法使用这些数据(会抛错)

模式二:

// 主线程
const worker = new Worker('worker.js');
const offscreenCanvas = canvas.transferControlToOffscreen();
worker.postMessage({
  canvas: offscreenCanvas,
}, [offscreenCanvas])


// worker
onmessage = async (event) => {
  const canvas = event.data.canvas;
  const ctx = canvas.getContext("2d");
  // ctx绘制工作   ...
  const imageBitmap = await self.createImageBitmap(new Blob([data.buffer]));
  // 开始渲染
  ctx.drawImage(imageBitmap, 0, 0);
}

一个对照试验:

对照主线程解码+主线程渲染(参考动图 1)
实验组demo1:主线程解码 + 主线程渲染-   解码被卡住 ❌-   渲染被卡住 ❌(参考动图 2)demo2:work 线程 解码 + 主线程canvas渲染-   解码不被卡住 ✅-   渲染被卡住 ❌(参考动图 3)
实验组demo3:worker 线程 解码 + offscreenCanvas(同步模式)-   解码不被卡住 ✅-   渲染被卡住 ❌(参考动图 4)demo4:worker 线程 解码 + offscreenCanvas( 异步模式 )-   解码不被卡住 ✅-   渲染不被卡住 ✅(参考动图 5)

动图 1

动图 2


动图 3

动图 4


动图 5


通过实验,我们可以发现:

解码任务放在worker线程,不会被主线程打断;渲染任务放在offscreenCanvas,不会被主线程打断

Q2:怎么保证预览效果和合成效果一致性?

这个问题比较容易理解,受限于浏览器自身的性能和限制,前端合成问题较多,稳定性和性能不足,所以是采用服务端合成的方式。

为了保证服务端导出和前端编辑预览一致,约定一个草稿协议,云端合成时基于草稿做类似前端合成操作即可


可以看到 ffmpeg + wasm + worker + offscreenCanvas搭配起来后,还是能做出一款性能不错的有意思的音视频小工具

我们进入一个小实战环节 👇

小实战探索:搭建一个 web版gif字幕离线生成器

之所以做下面这个实战

  • 一方面是因为之前在校弄过一个app,底层原理是在服务端做gif字幕的合成,合成时使用到了ffmpeg,和视频剪辑底层有些相似;
  • 另一方面是刚好尝试改造为web版本,以便实战下 ffmpeg + wasm + worker + offscreenCanvas

🍪 想要达到的目标:一个 web版gif字幕离线生成器(支持离线合成,也支持播控合成后的gif)

之前效果:


合成链路改造方案:


之前的链路,存在的问题:

  • 云端合成,流量大时服务器CPU容易被挤满,导致服务器不可用
  • 生成的gif不支持预览播控

具体链路


实现效果


具体代码

为了使用ffmpeg,我们需要将其编译为wasm,这里我们直接使用一个编译好的三方库 ffmpeg.wasm[8]

step1、加载worker

index.jsx

useEffect(() => {
  const gifWorker = new Worker('http://localhost:3000/gif_worker_offscreen.js');
  gifWorker.onmessage = function (msg) {
    if (msg.data.method === 'transfer') {
      setGifSrc(msg.data.url);
    }
  };
  setGifWorker(gifWorker);
}, []);

<>
    <canvas id='gif-canvas' ref={gifCanvasRef}/>
    <button onClick={() => {
      if (!gifWorker) {
        return;
      }
      // 定义一个离屏canvas
      const offscreenCanvas = gifCanvasRef.current.transferControlToOffscreen();
      gifWorker.postMessage({
        method: 'init'
        canvas: offscreenCanvas, 
        inputList: gifInputList,
      }, [offscreen]);
    }}>生成gif
    </button>
</>

step2、worker初始化,引入ffmepg

gif_worker_offscreen.js

importScripts('/ffmpeg.dev.js');
const {createFFmpeg, fetchFile} = self.FFmpeg;

const ffmpeg = createFFmpeg({
  corePath: 'http://localhost:3000/ffmpeg-core.js',
  ...
});

onmessage = async (event) => {
  const method = event.data.method;
  if (method === 'init') {
    if (!canvas) {
      canvas = event.data.canvas;
      ...
      ctx = canvas.getContext("2d");
    }
    await decodeResource();
    play();
    await playCore(ctx);
  } else if (method === 'pause') {
    pause();
  } else if (method === 'play') {
    play();
  } else if (method === 'replay') {
    ...
    playIndex = 0;
    await playCore(ctx);
  }
}

step3、合成gif

gif_worker_offscreen.js

async function decodeResource() {
  if (!ffmpeg.isLoaded()) {
    await ffmpeg.load();
  }
  ffmpeg.FS('writeFile''template.mp4', await fetchFile('http://localhost:3000/1/template.mp4'));
  ffmpeg.FS('writeFile''template.ass', await replaceAssTemplate(inputList));
  ffmpeg.FS('writeFile''tmp/Yahei', await fetchFile('http://localhost:3000/1/yahei.ttf'));
  await ffmpeg.run('-i''template.mp4''-vf'"subtitles=template.ass:fontsdir=/tmp:force_style='Fontname=Microsoft YaHei'"'export.gif');
  const data = ffmpeg.FS('readFile''export.gif');
  await ffmpeg.run('-i''export.gif''-vf''fps=25''-s''480x270''image%d.jpg');
  const url = URL.createObjectURL(new Blob([data.buffer], {type'image/gif'}));
  postMessage({method: "transfer", url});
}

step4、播控gif

gif_worker_offscreen.js

async function playCore(ctx) {
  const totalLength = Math.floor(duration / timeInterval);
  clearInterval(playTimer);
  playTimer = setInterval(async () => {
    if (!canPlay) {
      return;
    }
    playIndex++;
    if (playIndex === totalLength) {
      clearInterval(playTimer);
      return;
    }
    const data = ffmpeg.FS('readFile', `image${playIndex}.jpg`);
    const imageBitmap = await self.createImageBitmap(new Blob([data.buffer]));
    ctx.drawImage(imageBitmap, 0, 0);
  }, timeInterval);
}

总结

以上便是做剪辑工具过程中,发现的一些比较有意思的点,本篇文章属于抛砖引玉,每个方面大家都还可以继续深挖,发现更多有意思的点。

在后续的分享中,打算分享下 web剪辑编辑器 前端部分的具体实现(前端工程师会比较熟悉的领域)

❤️ 谢谢支持

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~。

欢迎关注公众号 ELab团队 收货大厂一手好文章~

我们来自字节跳动,是旗下大力教育前端部门,负责字节跳动教育全线产品前端开发工作。

我们围绕产品品质提升、开发效率、创意与前沿技术等方向沉淀与传播专业知识及案例,为业界贡献经验价值。包括但不限于性能监控、组件库、多端技术、Serverless、可视化搭建、音视频、人工智能、产品设计与营销等内容。

欢迎感兴趣的同学在评论区或使用内推码内推到作者部门拍砖哦 🤪

字节跳动校/社招投递链接: https://job.toutiao.com/s/2jML178

内推码:C4QC2V7

参考资料

[1]

多媒体前端技术入门指南 - TeqNG: https://www.teqng.com/2021/06/29/%E5%A4%9A%E5%AA%92%E4%BD%93%E5%89%8D%E7%AB%AF%E6%8A%80%E6%9C%AF%E5%85%A5%E9%97%A8%E6%8C%87%E5%8D%97/

[2]

腾讯云剪 - web多媒体技术在视频编辑场景的应用: https://cloud.tencent.com/developer/article/1694656

[3]

爱奇艺云剪辑Web端的技术实现: https://blog.51cto.com/u_15282126/3000742

[4]

ffmpeg: https://ffmpeg.org/ffmpeg.html

[5]

OffscreenCanvas: https://developer.mozilla.org/zh-CN/docs/Web/API/OffscreenCanvas

[6]

Transferable: https://developer.mozilla.org/zh-CN/docs/Web/API/Transferable

[7]

Transferable: https://developer.mozilla.org/zh-CN/docs/Web/API/Transferable

[8]

ffmpeg.wasm: https://ffmpegwasm.netlify.app/

[9]

asm.js 和 Emscripten 入门教程 - 阮一峰的网络日志: https://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html

[10]

Serverless Wasm: https://www.zhihu.com/column/c_1311629555841826816

[11]

webassembly 基础: https://quickapp.vivo.com.cn/webassembly/#toc-8

[12]

OffscreenCanvas - 概念说明及使用解析: https://zhuanlan.zhihu.com/p/34698375

[13]

OffscreenCanvas-离屏canvas使用说明: https://blog.csdn.net/netcy/article/details/103781610

- END -


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存