ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HTML视频渲染引擎开源:从原理到批量视频生产实践

HTML视频渲染引擎开源:从原理到批量视频生产实践

前两天刷到 HeyGen 官方仓库更新,看到他们把内部的 HTML 视频渲染引擎整包开源出来了。这个事在视频工具圈里传得挺快——以前你要用 code 写视频,基本得靠自定义引擎或者 Remotion 这类框架,现在 HeyGen 直接把生产环境在用的渲染链路全部放到了 GitHub 上,对做营销视频、数据动效、字幕条和批量视频的人来说,等于多了一条真正能落地的高效路线。这篇文章我就基于自己实际把玩和折腾的经验,聊聊这个渲染引擎到底解决什么问题、底层是怎么把 HTML 变成视频的,以及上手过程中最容易踩的几个坑。

1. 这个渲染引擎到底解决了什么问题

1.1 传统视频生产流程的痛点

先说一个我自己的感受。过去给客户做视频物料,一般两条路:要么开剪辑软件逐条拖素材,要么套 AE 模板改文字。前者的问题是批量生产能力几乎为零,一个二十条字幕条的视频能让人从早干到晚;后者虽然能自动化,但 AE 模板的修改入口极其封闭,想换字体、换排版、换数据源,往往得在表达式里挣扎半天。到了要做数据视频的时候更麻烦,图表每次更新都要手动截图再贴进时间线,错一个小数点就得重新导出一条片子。

这种状态下出现用 HTML 写视频的方案,天然就是奔着“消灭手工”来的。HTML、CSS、JavaScript 这三样是前端工程师每天都在写的东西,比 AE 的表达式更好理解,也比脚本拼接字符串更直观。HeyGen 这次把自家的渲染引擎开源,等于把一条原本在商业化产品内部跑通的链路直接开放出来,你不需要去理解视频编码的底层细节,只需要把页面写好,剩下的帧渲染、时间轴推进、音视频合成全部交给引擎处理。

1.2 HTML 作为视频描述语言的核心价值

为什么偏偏是 HTML?我这两年用下来的体会是,它的核心价值有三个。

第一是“所见即所得”的边界最清晰。HTML 文件本身就是一份完整的视频描述,文本是字幕、样式是颜色和字号、动画是运动轨迹,任何一段都可以单独拆出来审查。这比非线性剪辑里的图层结构更容易做版本管理,Git 里可以正儿八经地对比两次提交之间字幕文案和动画参数改了什么。

第二是生态链最全。CSS 动画、Canvas、SVG 乃至 WebGL,浏览器里能跑的东西都能进视频,不需要另学一套模板语言。数据可视化那边有大量现成的图表库,ECharts、D3 这类库跑在页面里生成图表,然后截帧导出,这就是一条异常顺畅的链路。

第三是渲染结果稳定。HTML 视频的本质是浏览器渲染,屏幕上看到什么,导出视频里就是什么,不会有“这边预览一个样、那边渲染另一个样”的经典翻车。对于要求严格的人来说,这是能把视频生产纳入测试体系的基线能力。我之前维护过一套用 Puppeteer 截图生成周报视频的方案,这三点优势是当时选型时最看重的,HeyGen 这套开源引擎解决的问题和它是同一个方向。

2. 核心原理:从 HTML 到视频的完整链路

2.1 渲染管线的整体拆解

渲染引擎听起来很神秘,拆开看就是一条非常朴素的流水线。我这里按自己翻源码和实测后的理解给你梳理一遍。

第一步是拉起一个无头浏览器实例,也就是 headless Chromium。这个实例不显示窗口,但会在内存里完整地解析 HTML、CSS 以及执行 JavaScript。第二步是把你的 HTML 入口加载进来,这时页面就处于“待播放”状态。第三步是时间轴控制,引擎在外面维护一个 timeline,不依赖页面内部的 setInterval 或 setTimeout,这样做是为了统一控制进度,避免多个动画各自为政。

第四步是逐帧截图。引擎停在某个时间点 t,然后通过浏览器调试协议向页面发一个截图请求,拿到当前帧的图像数据。这里有一点很关键:它截的不是视频流里的一帧,而是调用渲染接口拿到的这一时刻的完整画面。第五步是将帧数据送入编码器,工程上常见的做法是用 ffmpeg 做 H.264 编码,把连续的帧压成 MP4。如果配了音频轨,还会在最后一步做音视频 mux,得到可发布的文件。

这一整套链路最精妙的地方在于:帧截图和编码器是解耦的,意味着你可以选择 25fps 只截关键帧做预览,也可以 60fps 全量渲染成片,甚至单独抽出某一帧输出成高清封面图。这也是为什么它能在自动化生产场景里站得住脚。

2.2 时间轴与动画的推进方式

用 HTML 写视频绕不开一个概念:动画怎么和“帧”对齐。

关键点是,浏览器里的 CSS 动画是连续运行的,并没有明确的“帧”概念。引擎的做法通常是先让页面运行到某个时间点,然后通过 evaluate 注入一段脚本,把 document.timeline.currentTime 推进到对应位置,或者直接控制 CSS 动画的 animation-delay / animation-play-state 来控制进度。

实操上,你可以在页面上写一段这样的关键帧动画:

@keyframes slide-in { 0% { transform: translateX(-100%); } 100% { transform: translateX(0); } } .banner { animation: slide-in 0.6s ease-out both; }

这里 0.6s 是从动画开始的时长,它在整体时间轴里的位置可以由渲染引擎的 begin 参数指定。比如把这段动画设置在时间轴的第 2.4 秒开始,0.6 秒结束。

我在第一批测试时踩过一个坑:CSS 动画只跑一次,但如果页面被反复 seek 回看,动画状态不会自动复位。解决方法是用 Web Animations API 显式控制,比如:

const anim = element.animate( [ { transform: 'translateX(-100%)' }, { transform: 'translateX(0)' } ], { duration: 600, easing: 'ease-out', fill: 'both' } ); anim.currentTime = 300; // 手动跳到第 300ms

这样每个动画对象都可以由外部时间轴任意拨动,不会出现“回看后画面错乱”的问题。如果你的视频里有多个元素要按顺序出场,花一点点时间把动画全部改成 Web Animations API 管理,后面做循环渲染会省很多事。

2.3 为什么不是直接对着网页录屏

我知道有人会问:既然都是浏览器渲染,为什么不能直接开个浏览器窗口用录屏工具抓一遍?

我自己早期为了做产品演示视频,真的试过录屏。致命问题有三个。

第一个是帧率不稳定。浏览器渲染并不是定速每秒 30 帧,卡顿、GC 暂停都会让录出来的视频跳帧。逐帧渲染则完全不同,它每一帧都是等渲染完成后再截取的,不追求实时性,只追求准确性,出来的视频帧率是严格均匀的。

第二个是异步内容无法保证。页面里的懒加载图片、字体加载、请求回来的数据,在实时录屏时无法精确控制何时出现。逐帧渲染可以在截每一帧之前等待指定的异步任务全部结束,甚至先在整个时间轴里预跑一遍让资源全部加载完,再从第 0 秒开始正式出帧,这在录屏里是实现不了的。

第三个是灵活性。逐帧渲染可以只渲染第 100 帧到 200 帧,比如你的视频只有某一段图表需要重做,根本不必重渲染整个片子。录屏必须从头到尾完整录一遍。这三条理由放在批量生产的语境下,后者基本没有竞争力。

3. 上手实操:用 HTML 写出第一支视频

3.1 环境准备与仓库获取

实操这一步,我按最常见的开发环境来说。需要准备的东西不多:Node.js(建议 20 以上,太老的版本可能会碰到依赖编译问题)、npm 或 pnpm、一个装有 Chromium 依赖的系统环境。如果你本机已经有 Chrome,也可以让它直接复用,省去下载无头版本的体积。

先把仓库克隆下来:

git clone <仓库地址> heygen-render cd heygen-render npm install

然后准备一个 input 目录,比如叫 scenes,把你的 HTML 文件放进去。第一次跑通之前没有必要在源码上做任何改动,引擎默认的配置基本够用。

这里说一个环境上的注意点:如果你在公司内网或 HTTP 代理环境下工作,Chromium 下载失败的概率很高。遇到下载卡住,就在 npm 配置里指定镜像下载源,或者把已有的 Chrome 可执行文件路径写到配置文件里绕过自动下载。

3.2 一个最小可运行的 HTML 视频模板

我建议你的第一个 HTML 文件不要太复杂,先在画布上画一个居中标题、一段副标题、一个渐变背景,确认链路能跑通之后再往上加东西。

下面是当时我验证用的最小模板,注释里已经标清楚了每个区域的作用:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; background: linear-gradient(135deg, #0f172a, #1e293b); } .container { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; } .title { font-size: 96px; font-weight: 700; color: #f8fafc; opacity: 0; animation: fade-in 1s ease-out forwards; } .subtitle { font-size: 40px; color: #94a3b8; margin-top: 16px; opacity: 0; animation: fade-in 1s ease-out 0.2s forwards; } @keyframes fade-in { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } } </style> </head> <body> <div class="container"> <div class="title">Hello, HTML Video</div> <div class="subtitle">Built with the open-source render engine</div> </div> </body> </html>

整个文件的逻辑很直白:视口的物理尺寸由引擎通过配置控制,页面内所有元素按百分比或者 flex 布局自适应。标题先透明,1 秒内淡入并上移,副标题延迟 0.2 秒跟随。

这个模板在浏览器里打开就能预览,非常符合“所见即所得”的工作流。

3.3 分辨率、帧率、时长与导出配置

页面写好之后,接着在配置文件里设置视频参数。一个典型的配置长这样:

{ "width": 1920, "height": 1080, "fps": 30, "duration": 6, "scene": "scenes/hello.html", "output": "dist/hello.mp4", "codec": { "enabled": true, "bitrate": "8M" } }

这里每一处都有讲究,我逐个说。

width 和 height 决定了无头浏览器的视口大小,同时也决定了最终视频的像素尺寸。请务必和你的 HTML 根元素尺寸保持一致,不一致会出现裁切或留白。如果你的视频是竖屏信息流投放,把宽高改成 1080 和 1920,页面本身不用改。

fps 建议至少 30。如果内容以缓慢的文字滚动为主,25 也够;如果包含快速位移动效,比如图表柱状图跳动、文字扫光,建议 60,否则运动部分会肉眼可见地发糊。

duration 的单位是秒。这里要注意:duration 不是“页面总时长”,而是引擎每帧截图从第 0 秒到第 duration 秒的区间。如果你页面里的动画总时长超过 duration,超出的部分不会出现在视频里。

codec 里的 bitrate 是目标码率。1920x1080 30fps 的情况下,我一般用 6M 到 10M 之间。这个范围不是固定死的,画面内容越复杂,码率需求越高。纯色背景的片子 4M 都很清晰,全是噪点纹理的画面给 12M 都嫌不够。

跑渲染的命令一般是这样:

npm run render -- scenes/hello.html

或者根据仓库提供的能力,直接调用 CLI 传参。渲染过程中会看到类似下面的日志:

[render] frame 001/180 ... done [render] frame 002/180 ... done ... [encode] mp4 output: dist/hello.mp4 [encode] duration: 6.0s, fps: 30, size: 4.8MB

看到 encode 完成后,在 dist 目录里找到 mp4 文件用播放器打开确认画面,第一支 HTML 视频就算跑通了。

3.4 动态数据注入与批量生产

跑通静态模板只是第一步,真正让这条链路变得有价值的,是动态数据注入。

思路很简单:HTML 文件保持结构不变,把需要变化的内容用占位符写出来。比如:

<div class="title">{{ title }}</div> <div class="subtitle">{{ subtitle }}</div>

在渲染之前,用一个很简单的脚本读取当前这条数据记录,替换 HTML 里的占位符,再把替换后的内容写到临时目录里交给引擎渲染:

const html = fs.readFileSync('templates/title-card.html', 'utf-8'); const finalHtml = html .replace('{{ title }}', row.title) .replace('{{ subtitle }}', row.subtitle); fs.writeFileSync(`scenes/${row.id}.html`, finalHtml);

做完这一步,批量生产的闭环就出来了。每个用户来一条数据,你生成一个 HTML 文件,渲染一个视频。要么在 Node 脚本里排队跑,要么每次调用 CLI 传入不同输出路径。我实测过一批 200 条的视频,逐条渲染总共大概 40 分钟,处理完之后没有一条需要人工介入。

如果你要接图表数据,过程也一致:页面加载时通过 fetch 读取本地 JSON 文件,把数据渲染进 ECharts 图表。这个过程不能靠“运气”,建议在页面的全局对象上暴露一个 ready 标记,渲染引擎截帧前先检查这个标记是否已变为 true,确保图表真的画完了才出帧,否则第一帧就会是空白的图表区域。

4. 常见问题与排查技巧实录

4.1 中文变成方框与字体加载失败

我拿到这套引擎第一天就撞上了中文渲染问题。headless Chromium 不加载系统字体包,页面里如果只写了“微软雅黑”之类的 font-family,而在无头环境里根本没有这个字体,中文就会渲染成豆腐块。

解决路径是三条。首选:把字体文件下载到本地,用 @font-face 引用本地文件:

@font-face { font-family: 'Noto Sans SC'; src: url('./fonts/NotoSansSC-Regular.otf') format('opentype'); }

注意字体文件必须先于文本渲染完成。在页面顶部预加载,哪怕这段文本还未出现在视口中,也要保证字体资源被 CSS 引用到。第二选择是使用系统通用的无衬线字体栈,比如 font-family: sans-serif,无头环境通常能匹配到内置的 Noto Sans 类字体,虽然美观度一般,但至少不会出现方框。第三是在渲染前注入一个等待逻辑,监听 document.fonts.ready 之后再开始出帧:

await document.fonts.ready;

这个在截第一帧前执行一次即可。如果你发现某些字的粗细和设计稿对不上,多半是字体子集或字重文件没放全,检查 @font-face 里是否声明了对应的 font-weight。

4.2 画面裁切和视口不适配

第二个高频问题是“视频出来内容少一块”。排查方向非常明确:先看配置里的 width / height,再看页面根元素的样式。

我习惯在 HTML 根节点上强制这样写:

html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }

如果页面有滚动条,几乎可以肯定 overflow 没有隐藏。滚动条会出现不仅影响视觉,还可能让固定定位元素的位置偏移。另一个隐蔽坑是移动端 viewport 的 meta 标签:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

在无头浏览器里最好不要加这个标签,因为“device-width”在不同视口设置下可能不等于你配置的物理像素,导致布局尺寸和出图尺寸不一致。稳妥的做法是去掉 viewport meta,直接按配置的像素宽度设计页面。

4.3 动画掉帧与渲染不稳定

逐帧渲染理论上不存在“掉帧”,但如果你发现成片里动画运动有抖动、闪烁或不连贯,问题多半出在浏览器动画的帧内状态没有被完整推进。

一种常见情况是使用了依赖时间函数的动画库,它们内部的时钟由浏览器 timeline 驱动,在 seek 时会因为动画状态机的“已完成”标记提前退出。解决方向是像前面说的,改用 Web Animations API,并自行管理动画的 currentTime。另一种情况是页面里用了大量 box-shadow、filter、backdrop-filter 这类重渲染属性。每个元素的重绘成本在逐帧截图时会累加,渲染时间会明显变长,而且会导致部分帧出现半渲染状态。

折中方案是:能用 transform 和 opacity 实现的动效,不要用 box-shadow 和 filter 硬凑。比如阴影动的效果可以用一个带低透明度渐变的伪元素模拟,把 motion 全部规约到合成器友好的属性上,成片质量会肉眼可见地上升。

还有一种闪烁现象,原因是渐入动画的 fill-mode 不一致。如果一个元素在动画开始前使用了 fill: backwards,在动画结束前用了 fill: forwards,中间 seek 到一个未定义时间点时,浏览器会返回默认状态,画面就闪了一下。所有出场动画统一用 fill: both,并且把初始状态在样式中显式写出来,彻底杜绝歧义。

4.4 编码参数与画质权衡

出片画质不理想时,应该先去查编码链路而不是怀疑渲染。MP4 默认走 H.264,常见的质量参数是 CRF 和 preset。如果你的仓库存取帧之后直接调 ffmpeg,可以在配置里加上类似这样的参数:

ffmpeg -framerate 30 -i frames/%04d.png -c:v libx264 -crf 18 -preset medium output.mp4

CRF 值越低画质越高,18 是视觉无损的常见起点;低于 18 对纯扁平设计的短视频几乎没有肉眼提升,但文件体积会涨很多。preset 影响编码速度与压缩率的平衡,medium 是通用选择;如果你只做内部预览,用 veryfast 把出片速度拉上来,质量损失在扁平化设计上不明显。

另外提一个容易被忽略的点:输入帧的格式。如果渲染出来的帧是 PNG,每一帧都是无损的,编码环节不用担心质量损失。但如果你为了省磁盘开了 JPEG 帧输出,编码后会出现一圈一圈的压缩噪点,尤其是渐变背景上。除非存储极度紧张,否则坚持 PNG 帧是底线。

4.5 问题排查速查表

我把上面这些问题和排查方向整理成一张速查表,遇到情况可以直接照着定位:

现象可能原因优先解法
中文显示为方框无头环境缺少字体本地 @font-face 引用并预加载
画面缺少边缘内容视口与配置尺寸不一致去掉 viewport meta,根元素设 100% 宽高
动画中途闪烁fill-mode 不统一统一使用 fill: both
运动模糊或顿挫帧率过低或动画状态未复位提高 fps,改用 Web Animations API
渐变区域出现噪点使用了 JPEG 帧源改用 PNG 帧输出
首帧图表空白异步数据未返回等待 ready 标记后再截帧

这张表是我在实际渲染中反复对照过的,每一行都对应一次真实的翻车现场。把它贴在项目文档里,出问题的时候至少能少走一半弯路。

5. 场景化应用与扩展思路

5.1 适合用 HTML 写视频的典型场景

把个人经验总结下来,下面几类场景最适合这条链路。

批量短视频生产。给几十上百个商品各生成一段展示片,用同一个 HTML 模板跑不同图片和文案,两天的工作压缩到一个小时。营销活动落地页也适合,背景、动效、字体完全复用,只是换内容。

数据播报和图表视频。每周的销售周报、行情复盘、数据汇报,图表和文字经常要更新。用 ECharts 生成图表动画,配上标题字幕,定时任务在每周凌晨自动跑一批新视频,目前在自动化运营团队里是刚需。

字幕条和标题动画。品牌 LOGO 演绎、片头转场、字幕条这一类短平快的视频,过去用 AE 模板改一次需要重新导出,用 HTML 模板改一行字就能重新出片,而且字体、间距、颜色完全可控。

5.2 不适合的场景

也要诚实地说清楚短板。复杂 3D 场景、角色动画、物理模拟,这些在纯 HTML/JS 生态里能实现但成本极高,不如用传统 DCC 工具或游戏引擎。实拍画面的合成、绿幕抠像、真实光影处理,也不应该在这条链路里硬撑。长视频,比如超过 10 分钟的故事片、课程录播,渲染时长会线性增长,而且逐帧渲染的效率优势只在短视频里明显。

还有一个场景我踩过:需要强交互的演示视频,比如视频里有点击热区的原型演示,导出的 MP4 天然没有交互能力。这种需求应该用 HTML 页面本身去交付,而不是硬渲染成视频。

5.3 继续深挖的扩展方向

这套能力开箱即用,但真正的项目价值在扩展。

可以接入 CI/CD,让每次 git push 都自动渲染一台对应的视频版本。前端工程师对这套再熟悉不过,等于把视频纳入了持续集成的管理体系。可以做成 API 服务,接收参数返回视频文件,嵌入到 SaaS 平台里让运营自助出片。也可以对上层的模板市场,团队内部积累一套 HTML 视频模板库,谁需要谁取用,不用再依赖专职视频设计师。走得更远一点,可以把它和 AI 生成内容串起来:先用大模型生成镜头文案和分镜脚本,再用脚本自动组装 HTML 模板,最后渲染出片,整条链路从创意到成品的自动化程度会非常高。

我在实际动手的过程中最深的一个体会是:这套开源引擎的价值,很大程度取决于你把 HTML 当成了什么。如果你只是把它当成一个把文字变成画面的小工具,那它最大的能力也就是省了几次手动导出;如果你把它当成一个可以编程的视频输出层,嵌入到自动化的内容管线里,那它就是整个内容生产体系的新底座。Git 管理视频版本、CI 自动出片、按数据批量生成,这些过去想都不敢想的操作,现在就是几行配置和一段 HTML 的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进