梁晴天
哔哩哔哩高级开发工程师
想要开发一个具有视频合成功能的应用,从原理层面和应用层面都有一定的复杂度。原理上,视频合成需要应用使用各种算法对音视频数据进行编解码,并处理各类不同音视频格式的封装;应用上,视频合成流程较长,需要对多个输入文件进行并行处理,以实现视频滤镜、剪辑、拼接等功能,使用应用场景变得复杂。
视频合成应用的代表是各类视频剪辑软件,过去主要以原生应用的形式存在。近年来随着浏览器的接口和能力的不断开放,逐渐也有了Web端视频合成能力的解决思路和方案。
本文介绍的是一种基于FFmpeg + WebAssembly开发的视频合成能力,与社区既有的方案相比,此方案通过JSON来描述视频合成过程,可提高业务侧使用的便利性和灵活性,对应更多视频合成业务场景。
2023年上半年,基于AI进行内容创作的AIGC趋势来袭。笔者所在的团队负责B站的创作、投稿等业务,也在此期间参与了相关的AIGC创作工具类项目,并负责项目中的Web前端视频合成能力的开发。
如果需要在应用中引入音视频相关能力,目前业界常见的方案之一是使用FFmpeg。FFmpeg是知名的音视频综合处理框架,使用C语言写成,可提供音视频的录制、格式转换、编辑合成、推流等多种功能。
而为了在浏览器中能够使用FFmpeg,我们则需要WebAssembly + Emscripten这两种技术:
想要通过Emscripten将FFmpeg编译至WebAssembly,需要使用Emscripten。Emscripten本身是一系列编译工具的合称,它仿照gcc中的编译器、链接器、汇编器等程序的分类方式,实现了处理wasm32对象文件的对应工具,例如emcc用于编译到wasm32、wasm-ld用于链接wasm32格式的对象文件等。
而对于FFmpeg这个大型项目来说,其模块主要分为以下三个部分
自行编译FFmpeg到WebAsssembly难度较大,我们在实际在为项目落地时,选择了社区维护的版本。目前社区内维护比较积极,功能相对全面的是ffmpeg.wasm(https://github.com/ffmpegwasm/ffmpeg.wasm)项目。该项目作者也提供了如何自行编译FFmpeg到WebAssembly的系列博文(https://itnext.io/build-ffmpeg-webassembly-version-ffmpeg-js-part-1-preparation-ed12bf4c8fac)
FFmpeg本身是一个可执行命令行程序。我们可以通过为FFmpeg程序输入不同的参数,来完成各类不同的视频合成任务。例如在终端中输入以下命令,则可以将视频缩放至原来一半大小,并且只保留前5秒:
ffmpeg -i input.mp4 -vf scale=w='0.5*iw':h='0.5*ih' -t 5 output.mp4
图片
而在浏览器中,FFmpeg以及视频合成的运行机制如上所示:在业务层,我们为视频合成准备好需要的FFmpeg命令以及若干个输入文件,将其预加载到Emscripten模块的MEMFS(一种虚拟文件系统)中,并同时传递命令至Emscripten模块,最后通过Emscripten的胶水代码驱动WebAssembly进行逻辑计算。视频合成的输出视频会在MEMFS中逐步写入完成,最终可以被取回到业务层
上面的例子中,我们为FFmpeg输入了一个视频文件,以及一串命令行参数,实现了对视频的简单缩放加截断操作。实际情况下,业务侧产生的视频合成需求可能是千变万化的,这样直接调用FFmpeg的方式,会导致业务层需要处理大量代码处理命令行字符串的构建、组合逻辑,就显得不合适宜。同时,我们在项目实践的过程中发现,由于项目需要接入 WebCodecs 和 FFmpeg 两种视频合成能力,这就需要一个中间层,从上层接收业务层表达的视频合成意图,并传递到下层的WebCodecs 或 FFmpeg 进行具体的视频合成逻辑的“翻译”和执行。
API设计
图片
如上所示,描述一个视频合成任务,可以采用类似“基于时间轴的视频合成工程文件”的方式:在视频剪辑软件中,用户通过可视化的操作界面导入素材,向轨道上拖入素材成为片段,为每个片段设置位移、宽高、不透明度、特效等属性;同理,对于我们的项目来说,业务方自行准备素材资源,并按一定的结构搭建描述视频合成工程的对象树,然后调用中间层的方法执行合成任务。
图片
以上是我们最终形成的一个分层结构:
以上是我们最终实现的FFmpeg前端视频合成能力,各个模块在运行时的相互调用时序图。各个模块之间并不是简单地按顺序层层向下调用,再层层向上返回。有以下这些点值得注意
例如,业务方想要把一个宽高未知的视频片段,放置在最终合成视频(假设为1280x720)的正中央时,我们需要将视频片段的transform.left设置为(1280 - videoWidth) / 2,transform.top 设置为 (720 - videoHeight) / 2。这里的videoWidth, videoHeight就需要通过FFmpeg读取文件元信息得到。因此我们设计的流程中,需要对所有输入的资源文件进行预加载,再生成状态树。
实践过程中我们发现,业务方在使用FFmpeg能力时,至少需要使用以下三种不同的形式的输出结果:
因此我们为执行层的输出设计了这样的统一接口
export interface RunTaskResult { /** 日志树结果 */ log: LogNode /** 二进制文件结果 */ output: Uint8Array} function runProject(json: ProjectJson): { /** 事件结果 */ evt: EventEmitter<RunProjectEvents, any>; result: Promise<RunTaskResult>;}
runProject 函数是我们对外提供的视频合成的主函数。包含了“对输入JSON进行校验,补全、预加载文件并获取文件元信息、预加载字幕相关文件、翻译FFmpeg命令、执行、emit事件”等多种逻辑。
/** * 按照projectJson执行视频合成 * @public * @param json - 一个视频合成工程的描述JSON * @returns 一个evt对象,用以获取合成进度,以及异步返回的视频合成结果数据 */export function runProject(json: ProjectJson) { const evt = new EventEmitter<RunProjectEvents>() const steps = async () => { // hack 这里需要加入一个异步,使得最早在evt上emit的事件可以被evt.on所设置的回调函数监听到 await Promise.resolve() const parsedJson = ProjectSchema.parse(json) // 使用json schema验证并补全一些默认值 // 预加载并获取文件元信息 evt.emit('preload_all_start') const preloadedClips = [ ...await preloadAllResourceClips(parsedJson, evt), ...await preloadAllTextClips(parsedJson) ] // 预加载字幕相关信息 const subtitleInfo = await preloadSubtitle(parsedJson, evt) evt.emit('preload_all_end') // 生成project对象树 const projectObj = initProject(parsedJson, preloadedClips) // 生成ffmpeg命令 const { fsOutputPath, fsInputs, args } = parseProject(projectObj, parsedJson, preloadedClips, subtitleInfo) if (subtitleInfo.hasSubtitle) { fsInputs.push(subtitleInfo.srtInfo!, subtitleInfo.fontInfo!) } // 在ffmpeg任务队列里执行 const task: FFmpegTask = { fsOutputPath, fsInputs, args } // 处理进度事件 task.logHandler = (log) => { const p = getProgressFromLog(log, project.timeline.end) if (p !== undefined) { evt.emit('progress', p) } } evt.emit('start') // 返回执行日志,最终合成文件,事件等多种形式的结果 const res = runInQueue(task) await res evt.emit('end') return res } return { evt, result: steps() }}
FFmpeg命令的翻译流程,对应的是上述runProject方法中的parseProject,是在所有的上下文(视频合成描述JSON对象,状态树文件预加载后的元信息等)都齐备的情况下执行的。本身是一段很长,且下游较深的同步执行代码。这里用伪代码描述一下parseProject的过程
1. 实例化一个命令行参数操作对象ctx,此对象用于表达命令行参数的结构,可以设置有哪些输入(多个)和哪些输出(一个),并提供一些简便的方法用以操作filtergraph2. 初始化一个视频流的空数组layers(这里指广义的视频流,只要是有图像信息的输入流(例如视频、占一定时长的图片、文字片段转成的图片),都算作视频流);初始化一个音频流的空数组audios3. (作为最终合成的视频或音频内容的基底)在layers中加入一个颜色为project.backgroundColor, 大小为project.size,时长为无限长的纯色的视频流;在audios中加入一个无声的,时长为无限长的静音音频流4. 对于每一个project中的片段 1. 将片段中所包含的资源的url添加到ctx的输入数组中 2. (从所有已预加载的文件元信息中)找到这个片段对应的元信息(宽高、时长等) 3. (处理片段本身的截取、宽高、旋转、不透明度、动画等的处理)基于此片段的JSON定义和预加载信息,翻译成一组作用于该片段的FFmpeg filters,并且这一组filters之间需要相互串联,filters头部连接到此片段的输入流。得到片段对应的中间流。 4. 获取到的中间流,如果是广义的视频流的,推入layers数组;如果是广义的音频流的,推入audios数组5. 视频流layers数组做一个类似reduce的操作,按照画面中内容叠放的顺序,从最底层到最顶层,逐个合并流,得到单个视频流作为最终视频输出流。6. 音频流audios数组进行混音,得到单个音频流作为最终输出流。7. 调用ctx的toString方法,此方法是会将整个命令行参数结构输出为string。ctx下属的各类对象(Input, Option, FilterGraph)都有自己的toString方法,它们会依次层层toString,最终形成整体的ffmpeg命令行参数
适当的元素动画有助提高视频的画面丰富度,我们实现的视频合成能力中,也对元素动画能力进行了初步支持。
在视频剪辑软件中,为元素配置动画主要是基于关键帧模型,典型操作步骤如下:
在视频合成描述JSON中,我们参照了CSS动画声明进行了以下设计,来满足元素动画的配置
以下是元素动画配置的例子
// 视频片段bg.mp4,在画面的100,100处出现,并伴随有闪烁(不透明度从0到1再到0)的动画,动画延迟1秒,时长5秒{ "type": "video", "url": "/bg.mp4", "static": { "x": 100, "y": 100 }, "animation": { "properties": { "delay": 1, "duration": 5 }, "keyframes": { "0": { "opacity": 0 }, "50": { "opacity": 1 }, "100": { "opacity": 0 } } }}
动画效果的本质是一定时间内,元素的某个状态逐帧连续变化。而FFmpeg的视频合成的实际操作都是由filter完成的,所以想要在FFmpeg视频合成中添加动画,则需要视频类的filter支持按视频的当前时间,逐帧动态设置filter的参数值。
以overlay filter为例,此filter可以将两个视频层叠在一起,并设置位于顶层的视频相对位置。如果无需设置动画时,我们可以将参数写成overlay=x=100:y=100表示将顶层视频放置在距离底层视频左上角100,100的位置。
需要设置动画时,我们也可以设置x, y为包含了t变量(当前时间)的表达式。例如overlay=x=t*100:y=t*100,可以用来表达顶层视频从左上到右下的位移动画,逐帧计算可知第0秒坐标为0,0,第1秒时坐标为100,100,以此类推。
像overlay=x=expr:y=expr这样的,expr的部分被称为FFmpeg的表达式,它也可以看成是以时间(以及其他一些可用的变量)作为输入,以filter的属性值作为输出的函数。表达式中除了可以使用实数、t变量、各类算术运算符之外,还可以使用很多内置函数,具体可参考FFmpeg文档中对于表达式取值的说明(https://ffmpeg.org/ffmpeg-utils.html#Expression-Evaluation)
由于表达式的本质是函数,我们在把动画翻译成FFmpeg表达式时,可以先绘制动画的函数图像,然后再从FFmpeg表达式的可用变量、内置函数、运算符中,进行适当组合来还原函数图像。下面是一些常见的动画模式的FFmpeg表达式对应实现
假设对于某元素,我们设置了一个向上弹跳一次的动画,此动画有一定延迟,并且只循环一次,动画已结束后又过了一段时间,元素再消失。则此元素的y属性函数图像及其公式可能如下
图片
图片
通过以上函数图像我们可知,此类函数无法通过一个单一部分表达出来。在FFmpeg表达式中,我们需要将三个子表达式,按条件组合到一个大表达式中。对于分段的函数,我们可以使用FFmpeg自带的if(x,y,z)函数(类似脚本语言中的三元表达式)来等价模拟,将条件判断/then分支/else分支 这三个子表达式 分别传入并组合到一起。对于分支有两个以上的情况,则在else分支处再嵌入新的if(x,y,z)即可。
# 实际在生成表达式时,所有的换行和空格可以省略y=if( lt(t,2), # lt函数相当于<操作符 1, if( lt(t,4), sin(-PI*t/2)+1, 1 ))
我们可以实现一个递归函数nestedIfElse,来将N个条件判断表达式和N+1个分支表达式组合起来,成为一个大的FFmpeg表达式,用于分段动画的场景
function nestedIfElse(branches: string[], predicates: string[]) { // 如果只有一个逻辑分支,则返回此分支的表达式 if (branches.length === 1) { return branches[0] // 如果有两个逻辑分支,则只有一个条件判断表达式,使用if(x,y,z)组合在一些即可 } else if (branches.length === 2) { const predicate = predicates[0] const [ifBranch, elseBranch] = branches return `if(${predicate},${ifBranch},${elseBranch})` // 递归case } else { const predicate = predicates.shift() const ifBranch = branches.shift() const elseBranch = nestedIfElse(branches, predicates) as string return `if(${predicate},${ifBranch},${elseBranch})` }}
补帧是将关键帧间的空白填补,并连接为动画的基本方式。被补出来的每一帧中,对应的属性值需要使用插值函数进行计算。
对于线性插值,FFmpeg自带了lerp(x,y,z)函数,表示从x开始到y结束,按z的比例(z为0到1的比值)线性插值的结果。因此我们可以结合上面的if(x,y,z)函数的分段功能,实现一个多关键帧的线性补帧动画。例如,某属性有两个关键帧,在t1时属性值为a,在t2时属性值为b,则补帧表达式为
图片
对于非线性补帧,我们可以将其理解为在上述线性补帧公式的基础上,将lerp(x,y,z)函数的z参数(进度的比例)再进行一次变换,使得动画的行进变得不均匀即可。以下公式中的t'代表了一种典型的缓慢开始和缓慢结束的缓动函数(timing function),将其代入原公式即可
图片
(图中展示了从左下角的关键帧到右上角的关键帧的线性/非线性 补帧的函数图像)
以下是对应的代码实现
// 假设有关键帧(t1, v1)和(t2, v2),返回这两个关键帧之间的非线性补帧表达式function easeInOut( t1: number, v1: number, t2: number, v2: number) { const t = `t-${t1})/(${t2-t1})` const tp = `if(lt(${t},0.5),4*pow(${t},3),1-pow(-2*${t}+2,3)/2)` return `lerp(${v1},${v2},${tp})`}
如果我们需要表达一个带有循环的动画,最直接的方式是将某个时段上的映射关系,复制并平移到其他的时段上。例如,想要实现一个从画面左侧平移至右侧的动画,重复多次时,我们可能使用下面这样的函数
图片
以上使用分段函数的写法的问题在于,如果循环次数过多时,函数的分支较多,产生的表达式很长,也会影响在视频合成时对表达式求值的性能。
事实上,我们可以引入FFmpeg表达式中自带的mod(x,y)函数(取余操作)来实现循环。由于取余操作常用来生成一个固定范围内的输出,例如不断重复播放的过程。上面的函数,在引入mod(x,y)后,可以简化为 x=mod(t,1)。
上述对于动画分段、循环、补帧如何实现的问题,其共通点都是如何找到其对应函数,并在FFmpeg中翻译为对应的表达式,或者对已有表达式进行组合。
据此,我们实现了KFAttr(关键帧属性,用以封装关键帧和动画全局配置等信息)和TimeExpr(以KFAttr作为入参,并翻译为FFmpeg表达式)两个类。其中,TimeExpr的整体算法大致如下:
1.将动画分成前,中,后三部分。前半部分是由于delay配置导致的,元素已出现但动画还未开始的静止部分;中间部分是动画的主体部分;后半部分是由于动画重复次数较少,元素未消失但动画已结束的静止部分
2.对于前半部分,表达式设置为等于关键帧中第一帧的值;对于后半部分,表达式设置为等于关键帧中最后一值的值
3.对于中间部分
4.再次使用nestedIfElse,将前、中、后三部分组合成最终的表达式
在项目实践的过程中,我们发现浏览器中通过ffmpeg.wasm进行视频合成时,有一定机率出现内存不足的现象。表现为以下Emscripten的运行时报错(OOM为Out of memory的缩写)
exception thrown: RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.
分析后我们认为,内存不足的问题主要是由于以下这些因素导致的
为了应对以上问题,在实践中,我们采取了以下这些策略,来减少内存不足导致的合成失败率:
视频合成的过程出现了并发时,会加剧内存不足现象的产生。因此我们在runProject以及其他FFmpeg执行方法背后实现了一个统一的任务队列,确保一个任务在执行完成后再进行下一个任务,并且在下一个任务开始执行前,重启ffmpeg.wasm的运行时,实现内存垃圾回收。
实践中我们发现,如果一个FFmpeg命令中输入的音视频素材文件过多时,即使这些素材在时间线上都重叠(也就是某一时间点上,所有的素材视频画面都需要出现在最终画面中)的情况很少,也会大大提高内存不足的概率。
我们采取了对视频合成的结果进行时间分段的策略。根据每个片段在时间轴上的分布情况,将整个视频合成的FFmpeg任务,拆分成多个规模更小的FFmpeg任务。每个任务仅需要2-3个输入文件(常规的视频合成需求中,同屏同时播放的视频最多也在3个左右),各任务单独进行视频合成,最后再使用FFmpeg的concat功能,将视频前后相接即可。
视频合成的重编码(解码输入文件,操作数据并再编码),会消耗大量的CPU和内存资源。而视频和音频的前后拼接操作,则无需重编码,可以在非常短的时间内完成。
对于不太复杂的视频合成场景,往往并不是画面的每一帧都需要重新编码再输出的。我们可以分析视频合成的时间轴,找出不需要重编码的时间段(指的是此时画面内容仅来自一个输入文件,并且没有缩放旋转等滤镜效果,没有其他层叠的内容的时间段)。对这些时间段,我们通过FFmpeg的流拷贝功能截取出来(通过-vcodec copy命令行参数实现)即可,这样进一步减少了CPU和内存的消耗。
在视频中添加文字是视频合成的常见需求,这类需求可以大致分为两种情况:少量的样式复杂的艺术字,大量的字幕文字。
FFmpeg自带的filters中提供了以下的文字绘制能力,包括:
最初在支持视频合成方案的文字能力时,我们选择了后者的文字转图片技术,基本满足了业务需求。这一做法的优势在于:复用DOM的文字渲染能力,绘制效果好并且支持的文字样式丰富;并且由于转换为图片处理,可以让文字直接支持缩放、旋转、动画等许多已经在图片上实现的能力。
但正如上面提到的“为FFmpeg的命令一次性输入过多的文件容易引起OOM”的问题,文字转为图片后,视频合成时需要额外导入的图片输入文件也增加了。这也促使我们开始关注FFmpeg自带的文字渲染能力。
FFmpeg自带subtitles, drawtext等文字渲染能力,底层都使用了C语言的字体字符库(包括freetype字体光栅化,harfbuzz文字塑形,fribidi双向编码等),在每一帧编码前的filter阶段,将字符按指定的字体和样式即时绘制成位图,并与当前的framebuffer混合来实现的。这种做法会耗费更多的计算资源,但同时因为不需要缓存或文件,使用的内存更少。因此我们对于制作字幕这样需要大量添加固定样式的文字的场景,提供了相应的JSON配置,并在底层使用FFmpeg的subtitles filter进行绘制,避免了OOM的问题。
基于浏览器和FFmpeg本身的现有能力,在视频中添加文字的方案还可以有更多探索的可能。例如可以“使用SVG来声明文字的内容和样式,并在FFmpeg侧进行渲染”来实现。SVG方案的优点在于:文字的样式控制能力强;可以随意添加任意的文字的前景、背景矢量图形;与位图相比占用资源少等。后续在进行自编译的FFmpeg WebAssembly版相关调研时,会尝试支持。
通过 Emscripten 移植到浏览器运行的 FFmpeg,在性能上与原生FFmpeg有很大差距,大体原因在于浏览器作为中间环境,其现有的API能力不足,以及一些安全政策的限制,导致 FFmpeg 对于硬件能力的利用受限。随着浏览器能力和API的逐步演进,FFmpeg + WebAssembly 的编译、运行方式都可以与时俱进,以达到提高性能的目的。目前可以预见的一些优化点有:
本文链接://www.dmpip.com//www.dmpip.com/showinfo-26-75342-0.htmlFFmpeg前端视频合成实践
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 终于有篇文章把后管权限系统设计讲清楚了