前言
本文介绍了基于 + 开发的视频合成能力,通过 JSON 描述视频合成过程,提高业务侧使用便利性。采用分层设计,实现了状态层、执行层和业务层的视频合成能力,满足不同形式的输出需求。通过实现执行主流程 函数,完成视频合成的主要逻辑,包括校验、补全、预加载、翻译 命令和执行等步骤。今日前端早读课文章由 @梁晴天分享,公号:哔哩哔哩技术授权。
正文从这开始~~
视频合成能力的开发背景
想要开发一个具有视频合成功能的应用,从原理层面和应用层面都有一定的复杂度。原理上,视频合成需要应用使用各种算法对音视频数据进行编解码,并处理各类不同音视频格式的封装;应用上,视频合成流程较长,需要对多个输入文件进行并行处理,以实现视频滤镜、剪辑、拼接等功能,使用应用场景变得复杂。
视频合成应用的代表是各类视频剪辑软件,过去主要以原生应用的形式存在。近年来随着浏览器的接口和能力的不断开放,逐渐也有了 Web 端视频合成能力的解决思路和方案。
本文介绍的是一种基于 + 开发的视频合成能力,与社区既有的方案相比,此方案通过 JSON 来描述视频合成过程,可提高业务侧使用的便利性和灵活性,对应更多视频合成业务场景。
2023 年上半年,基于 AI 进行内容创作的 AIGC 趋势来袭。笔者所在的团队负责 B 站的创作、投稿等业务,也在此期间参与了相关的 AIGC 创作工具类项目,并负责项目中的 Web 前端视频合成能力的开发。
技术选型
如果需要在应用中引入音视频相关能力,目前业界常见的方案之一是使用 。 是知名的音视频综合处理框架,使用 C 语言写成,可提供音视频的录制、格式转换、编辑合成、推流等多种功能。
而为了在浏览器中能够使用 ,我们则需要 + 这两种技术:
编译 至
想要通过 将 编译至 ,需要使用 。 本身是一系列编译工具的合称,它仿照 gcc 中的编译器、链接器、汇编器等程序的分类方式,实现了处理 对象文件的对应工具,例如 emcc 用于编译到 、wasm-ld 用于链接 格式的对象文件等。
而对于 这个大型项目来说,其模块主要分为以下三个部分
自行编译 到 难度较大,我们在实际在为项目落地时,选择了社区维护的版本。目前社区内维护比较积极,功能相对全面的是 .wasm()项目。该项目作者也提供了如何自行编译 到 的系列博文()
在浏览器的运行
本身是一个可执行命令行程序。我们可以通过为 程序输入不同的参数,来完成各类不同的视频合成任务。例如在终端中输入以下命令,则可以将视频缩放至原来一半大小,并且只保留前 5 秒:
ffmpeg -i input.mp4 -vf scale=w='0.5*iw':h='0.5*ih' -t 5 output.mp4
而在浏览器中, 以及视频合成的运行机制如上所示:在业务层,我们为视频合成准备好需要的 命令以及若干个输入文件,将其预加载到 模块的 MEMFS(一种虚拟文件系统)中,并同时传递命令至 模块,最后通过 的胶水代码驱动 进行逻辑计算。视频合成的输出视频会在 MEMFS 中逐步写入完成,最终可以被取回到业务层
对 命令行界面进行封装
上面的例子中,我们为 输入了一个视频文件,以及一串命令行参数,实现了对视频的简单缩放加截断操作。实际情况下,业务侧产生的视频合成需求可能是千变万化的,这样直接调用 的方式,会导致业务层需要处理大量代码处理命令行字符串的构建、组合逻辑,就显得不合适宜。同时,我们在项目实践的过程中发现,由于项目需要接入 和 两种视频合成能力,这就需要一个中间层,从上层接收业务层表达的视频合成意图,并传递到下层的 或 进行具体的视频合成逻辑的 “翻译” 和执行。
API 设计
如上所示,描述一个视频合成任务,可以采用类似 “基于时间轴的视频合成工程文件” 的方式:在视频剪辑软件中,用户通过可视化的操作界面导入素材,向轨道上拖入素材成为片段,为每个片段设置位移、宽高、不透明度、特效等属性;同理,对于我们的项目来说,业务方自行准备素材资源,并按一定的结构搭建描述视频合成工程的对象树,然后调用中间层的方法执行合成任务。
分层设计
以上是我们最终形成的一个分层结构:
执行流程
以上是我们最终实现的 前端视频合成能力,各个模块在运行时的相互调用时序图。各个模块之间并不是简单地按顺序层层向下调用,再层层向上返回。有以下这些点值得注意
状态树,是 JSON + 文件元信息综合生成的
例如,业务方想要把一个宽高未知的视频片段,放置在最终合成视频(假设为 )的正中央时,我们需要将视频片段的 .left 设置为(1280 - ) / 2, 设置为(720 - ) / 2。这里的 , 就需要通过 读取文件元信息得到。因此我们设计的流程中,需要对所有输入的资源文件进行预加载,再生成状态树。
输出结果多样化
实践过程中我们发现,业务方在使用 能力时,至少需要使用以下三种不同的形式的输出结果:
因此我们为执行层的输出设计了这样的统一接口
export interface RunTaskResult {
/** 日志树结果 */
log: LogNode
/** 二进制文件结果 */
output: Uint8Array
}
function runProject(json: ProjectJson): {
/** 事件结果 */
evt: EventEmitter<RunProjectEvents, any>;
result: Promise<RunTaskResult>;
}
部分代码实现执行主流程
函数是我们对外提供的视频合成的主函数。包含了 “对输入 JSON 进行校验,补全、预加载文件并获取文件元信息、预加载字幕相关文件、翻译 命令、执行、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()
}
}
翻译流程
命令的翻译流程,对应的是上述 方法中的 ,是在所有的上下文(视频合成描述 JSON 对象,状态树文件预加载后的元信息等)都齐备的情况下执行的。本身是一段很长,且下游较深的同步执行代码。这里用伪代码描述一下 的过程
视频流 数组做一个类似 的操作,按照画面中内容叠放的顺序,从最底层到最顶层,逐个合并流,得到单个视频流作为最终视频输出流。
音频流 数组进行混音,得到单个音频流作为最终输出流。
调用 ctx 的 方法,此方法是会将整个命令行参数结构输出为 。ctx 下属的各类对象 (Input, , ) 都有自己的 方法,它们会依次层层 ,最终形成整体的 命令行参数
动画能力
适当的元素动画有助提高视频的画面丰富度,我们实现的视频合成能力中,也对元素动画能力进行了初步支持。
业务端如何配置动画
在视频剪辑软件中,为元素配置动画主要是基于关键帧模型,典型操作步骤如下:
在视频合成描述 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,所以想要在 视频合成中添加动画,则需要视频类的 支持按视频的当前时间,逐帧动态设置 的参数值。
以 为例ffmpeg,此 可以将两个视频层叠在一起,并设置位于顶层的视频相对位置。如果无需设置动画时,我们可以将参数写成 =x=100:y=100 表示将顶层视频放置在距离底层视频左上角 100,100 的位置。
需要设置动画时,我们也可以设置 x, y 为包含了 t 变量(当前时间)的表达式。例如=x=t*100:y=t*100,可以用来表达顶层视频从左上到右下的位移动画,逐帧计算可知第 0 秒坐标为 0,0,第 1 秒时坐标为 100,100,以此类推。
像 =x=expr:y=expr 这样的,expr 的部分被称为 的表达式,它也可以看成是以时间(以及其他一些可用的变量)作为输入,以 的属性值作为输出的函数。表达式中除了可以使用实数、t 变量、各类算术运算符之外,还可以使用很多内置函数,具体可参考 文档中对于表达式取值的说明(#-)
常见动画模式的表达式总结
由于表达式的本质是函数,我们在把动画翻译成 表达式时,可以先绘制动画的函数图像,然后再从 表达式的可用变量、内置函数、运算符中,进行适当组合来还原函数图像。下面是一些常见的动画模式的 表达式对应实现
动画的分段
假设对于某元素,我们设置了一个向上弹跳一次的动画,此动画有一定延迟,并且只循环一次,动画已结束后又过了一段时间,元素再消失。则此元素的 y 属性函数图像及其公式可能如下
通过以上函数图像我们可知,此类函数无法通过一个单一部分表达出来。在 表达式中,我们需要将三个子表达式,按条件组合到一个大表达式中。对于分段的函数,我们可以使用 自带的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
)
)
我们可以实现一个递归函数 ,来将 N 个条件判断表达式和 N+1 个分支表达式组合起来,成为一个大的 表达式,用于分段动画的场景
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})`
}
}
线性和非线性补帧
补帧是将关键帧间的空白填补,并连接为动画的基本方式。被补出来的每一帧中,对应的属性值需要使用插值函数进行计算。
对于线性插值, 自带了lerp(x,y,z)函数,表示从 x 开始到 y 结束,按 z 的比例(z 为 0 到 1 的比值)线性插值的结果。因此我们可以结合上面的if(x,y,z)函数的分段功能,实现一个多关键帧的线性补帧动画。例如,某属性有两个关键帧,在 t1 时属性值为 a,在 t2 时属性值为 b,则补帧表达式为
对于非线性补帧,我们可以将其理解为在上述线性补帧公式的基础上,将 lerp (x,y,z) 函数的 z 参数(进度的比例)再进行一次变换,使得动画的行进变得不均匀即可。以下公式中的 t' 代表了一种典型的缓慢开始和缓慢结束的缓动函数 ( ),将其代入原公式即可
图中展示了从左下角的关键帧到右上角的关键帧的线性 / 非线性 补帧的函数图像
以下是对应的代码实现
// 假设有关键帧(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})`
}
循环
如果我们需要表达一个带有循环的动画,最直接的方式是将某个时段上的映射关系,复制并平移到其他的时段上。例如,想要实现一个从画面左侧平移至右侧的动画,重复多次时,我们可能使用下面这样的函数
以上使用分段函数的写法的问题在于,如果循环次数过多时,函数的分支较多,产生的表达式很长,也会影响在视频合成时对表达式求值的性能。
事实上,我们可以引入 表达式中自带的 mod (x,y) 函数(取余操作)来实现循环。由于取余操作常用来生成一个固定范围内的输出,例如不断重复播放的过程。上面的函数,在引入mod(x,y)后,可以简化为x=mod(t,1)。
上述对于动画分段、循环、补帧如何实现的问题,其共通点都是如何找到其对应函数,并在 中翻译为对应的表达式,或者对已有表达式进行组合。
据此,我们实现了 (关键帧属性,用以封装关键帧和动画全局配置等信息)和 (以 作为入参,并翻译为 表达式)两个类。其中, 的整体算法大致如下:
再次使用 ,将前、中、后三部分组合成最终的表达式
浏览器里视频合成的内存不足问题
在项目实践的过程中,我们发现浏览器中通过 .wasm 进行视频合成时,有一定机率出现内存不足的现象。表现为以下 的运行时报错(OOM 为 Out of 的缩写)
exception thrown: RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.
分析后我们认为,内存不足的问题主要是由于以下这些因素导致的
为了应对以上问题,在实践中,我们采取了以下这些策略,来减少内存不足导致的合成失败率:
视频合成的严格串行执行
视频合成的过程出现了并发时,会加剧内存不足现象的产生。因此我们在 以及其他 执行方法背后实现了一个统一的任务队列,确保一个任务在执行完成后再进行下一个任务,并且在下一个任务开始执行前,重启 .wasm 的运行时,实现内存垃圾回收。
时间分段,多次合成
实践中我们发现,如果一个 命令中输入的音视频素材文件过多时,即使这些素材在时间线上都重叠(也就是某一时间点上,所有的素材视频画面都需要出现在最终画面中)的情况很少,也会大大提高内存不足的概率。
我们采取了对视频合成的结果进行时间分段的策略。根据每个片段在时间轴上的分布情况,将整个视频合成的 任务,拆分成多个规模更小的 任务。每个任务仅需要 2-3 个输入文件(常规的视频合成需求中,同屏同时播放的视频最多也在 3 个左右),各任务单独进行视频合成,最后再使用 的 功能,将视频前后相接即可。
减少重编码的场景
视频合成的重编码(解码输入文件,操作数据并再编码),会消耗大量的 CPU 和内存资源。而视频和音频的前后拼接操作,则无需重编码,可以在非常短的时间内完成。
对于不太复杂的视频合成场景,往往并不是画面的每一帧都需要重新编码再输出的。我们可以分析视频合成的时间轴,找出不需要重编码的时间段(指的是此时画面内容仅来自一个输入文件,并且没有缩放旋转等滤镜效果,没有其他层叠的内容的时间段)。对这些时间段,我们通过 的流拷贝功能截取出来(通过 - copy 命令行参数实现)即可,这样进一步减少了 CPU 和内存的消耗。
在视频中添加文字的实践
在视频中添加文字是视频合成的常见需求,这类需求可以大致分为两种情况:少量的样式复杂的艺术字,大量的字幕文字。
自带的 中提供了以下的文字绘制能力,包括:
最初在支持视频合成方案的文字能力时,我们选择了后者的文字转图片技术,基本满足了业务需求。这一做法的优势在于:复用 DOM 的文字渲染能力,绘制效果好并且支持的文字样式丰富;并且由于转换为图片处理,可以让文字直接支持缩放、旋转、动画等许多已经在图片上实现的能力。
但正如上面提到的 “为 的命令一次性输入过多的文件容易引起 OOM” 的问题,文字转为图片后,视频合成时需要额外导入的图片输入文件也增加了。这也促使我们开始关注 自带的文字渲染能力。
自带 , 等文字渲染能力,底层都使用了 C 语言的字体字符库(包括 字体光栅化, 文字塑形, 双向编码等),在每一帧编码前的 阶段,将字符按指定的字体和样式即时绘制成位图,并与当前的 混合来实现的。这种做法会耗费更多的计算资源,但同时因为不需要缓存或文件,使用的内存更少。因此我们对于制作字幕这样需要大量添加固定样式的文字的场景,提供了相应的 JSON 配置,并在底层使用 的 进行绘制,避免了 OOM 的问题。
基于浏览器和 本身的现有能力,在视频中添加文字的方案还可以有更多探索的可能。例如可以 “使用 SVG 来声明文字的内容和样式,并在 侧进行渲染” 来实现。SVG 方案的优点在于:文字的样式控制能力强;可以随意添加任意的文字的前景、背景矢量图形;与位图相比占用资源少等。后续在进行自编译的 版相关调研时,会尝试支持。
后续迭代
通过 移植到浏览器运行的 ,在性能上与原生 有很大差距,大体原因在于浏览器作为中间环境,其现有的 API 能力不足,以及一些安全政策的限制,导致 对于硬件能力的利用受限。随着浏览器能力和 API 的逐步演进, + 的编译、运行方式都可以与时俱进,以达到提高性能的目的。目前可以预见的一些优化点有:
关于本文
限时特惠:本站持续每日更新海量各大内部创业课程,一年会员仅需要98元,全站资源免费下载
点击查看详情
站长微信:Jiucxh