
1. 从一行 HTML 到一段 MP4这个开源项目到底在解决什么问题第一次看到“写 HTML 就能出视频”这个说法我脑子里蹦出来的第一个念头是这不就是把网页录屏吗但仔细扒完 HeyGen 开源的这套渲染引擎之后我发现事情远没有“录屏”这么简单。它真正想做的事情是把浏览器里那套成熟的排版、动画、样式能力直接变成一条可编程、可批量、可服务端运行的视频生产线。你写的是 HTML、CSS、JavaScript出来的却是一个实打实的 MP4 文件中间不需要你打开任何图形界面也不需要你手动点“开始录制”。这件事的价值在哪里我举个自己踩过的场景。之前帮一个做电商的朋友批量生成商品短视频团队一开始的思路是用剪辑软件套模板人工替换文案和图片。做到第 50 条的时候大家就崩溃了——改一个字体要重新导出换一张主图要重新对齐稍微复杂一点的动画就得回到时间线上一帧一帧调。后来我们转向“代码生成视频”的思路核心诉求就三条模板可复用、内容可参数化、渲染可自动化。HeyGen 这套引擎恰好命中了这三点而且它选择了一条最讨巧的技术路线——不去重新发明一套动画 DSL而是直接站在 Web 标准之上。所以这篇文章我想聊的不是“又一个开源项目发布了”这种新闻式的内容而是想把它拆开揉碎讲清楚三件事这套引擎背后的核心技术原理是什么为什么用 HTML 做视频输入是一个聪明的选择以及如果你要自己动手复现一套类似的“HTML 转视频”流水线具体该怎么落地、会遇到哪些坑。适合谁看前端工程师、做 AIGC 工具链的开发者、需要批量产出视频的内容团队以及所有对“声明式渲染”感兴趣的技术人。哪怕你只是写过最基础的 HTML也能看懂大半因为这套东西的底层逻辑和你每天写的网页没什么两样。2. 核心原理拆解HTML 是怎么一步步变成 MP4 的2.1 为什么是 HTML而不是自研一套动画格式要理解这个项目的设计取舍得先想明白一个根本问题做视频渲染为什么不去定义一套自己的动画描述语言反而要用 HTML我一开始也觉得奇怪直到自己尝试写过一个小型的视频生成工具才明白——自研格式的成本不在渲染而在生态。你想想如果自研一套动画格式你得自己实现布局引擎文字怎么换行、盒子怎么排列、自己实现样式系统颜色、渐变、阴影、圆角、自己实现动画曲线缓动、关键帧、时间轴还要自己写文档、做调试工具、培养用户习惯。这是一整个浏览器团队的工作量。而 HTML CSS JS 这套东西已经被打磨了二十多年任何一个前端都能上手任何一个设计工具都能导出任何一个 AI 模型都能生成。站在这个巨人肩膀上你只需要专注做一件事把“时间”这个维度加进去然后把渲染结果一帧一帧地抓出来编码成视频。这就是这套引擎最核心的思路。它本质上是一个确定性的、可逐帧驱动的浏览器渲染器。普通浏览器是“实时渲染”你看到什么就是什么帧率取决于性能和屏幕刷新而视频渲染需要的是“确定性渲染”——给定时间点 t必须精确地渲染出第 t 秒那一帧的画面不能多一毫秒也不能少一毫秒否则导出的视频就会抖动、丢帧、音画不同步。2.2 逐帧渲染与时间轴控制把“实时”变成“可暂停”我打个比方帮助理解。普通网页动画就像放烟花点燃之后它自己按真实时间往下走你没法让它停在某一刻。而视频渲染需要的是“逐格动画”——你手里有一个可以精确拨动的时间旋钮拨到 0.5 秒画面就必须是 0.5 秒那一帧的样子拨到 3.2 秒就跳到 3.2 秒。实现这个“时间旋钮”的常见做法是接管浏览器的动画时钟。Web 标准里有一套requestAnimationFrame机制它依赖真实时间。要做确定性渲染就得把它替换成一个虚拟时钟渲染引擎自己维护一个currentTime变量所有动画、过渡、定时器都从这个变量取值而不是从系统时间取值。这样引擎就能以任意步长推进时间——比如按 30fps 推进每次currentTime 1/30渲染一帧截图再推进再截图。这里有个关键细节很多人会忽略CSS 动画和 JS 动画的时间源必须统一。如果 CSS 动画走的是浏览器真实时钟而 JS 动画走的是虚拟时钟两者就会错位。所以成熟的实现通常会在渲染前注入一段脚本把Date.now()、performance.now()、requestAnimationFrame全部劫持到虚拟时钟上确保整个页面里所有跟时间相关的东西都听同一个指挥。2.3 从帧到视频编码环节的技术选型抓到了一堆帧图片之后下一步是把它们编码成 MP4。这一步的技术选型直接决定了整个引擎的性能和输出质量。常见的方案有这么几种我整理成表格方便对比方案原理优点缺点适用场景逐帧截图 FFmpeg 管道每帧存成 PNG用 FFmpeg 合成实现简单质量高磁盘 IO 大速度慢低频、高质量输出逐帧截图 流式编码帧直接喂给编码器不落盘速度快省磁盘实现复杂需处理背压批量、服务端渲染Canvas 直接录制用 MediaRecorder 录 canvas浏览器原生支持帧率不稳定质量不可控快速原型WebCodecs 硬编码调用浏览器底层编解码性能最好延迟低兼容性要求高现代浏览器环境HeyGen 这类面向生产环境的引擎通常会走流式编码这条路。原因很直接视频渲染是典型的 CPU 密集型任务如果每帧都先写磁盘再读回来编码IO 就会成为瓶颈。把渲染出来的帧通过管道直接推给编码器能让渲染和编码并行起来整体吞吐量能提升好几倍。我实测过一个类似的流水线同样的素材流式方案比落盘方案快了将近 3 倍尤其是在 1080p 以上分辨率时差距更明显。提示如果你自己搭这套流水线编码器参数里-pix_fmt yuv420p这个选项几乎是必须的。很多播放器和平台只认这个像素格式不加的话导出的视频在部分设备上会显示成绿屏或者无法播放。这是我自己踩过的坑排查了大半天才定位到。3. 动手复现搭一条自己的 HTML 转视频流水线3.1 环境准备与依赖选择理论讲完了咱们来点能直接抄作业的。要复现一条“HTML 进、MP4 出”的流水线你需要准备的东西其实不多但每一样的选择都有讲究。首先是无头浏览器。这是整个流水线的核心负责渲染 HTML。目前主流的选择是 Puppeteer基于 Chromium和 Playwright。我个人的建议是优先用 Playwright原因是它对多浏览器的支持更统一API 设计也更现代而且内置了很多等待和截图相关的便利方法。Puppeteer 的优势是生态更老、资料更多遇到问题更容易搜到答案。两者都能用看你团队的技术栈。其次是编码工具。FFmpeg 基本是绕不开的它稳定、参数丰富、社区庞大。如果你追求极致性能可以考虑用 WebCodecs 在浏览器内部完成编码省掉进程间通信的开销但兼容性和调试成本会高一些。最后是运行环境。这里有个容易被忽视的点无头浏览器在服务器上跑需要一堆系统依赖库字体、图形库、音频库等。如果你用 Docker建议直接基于官方的 Playwright 或 Puppeteer 镜像来构建能省掉大量“缺这个库缺那个库”的折腾。我见过太多人卡在“本地能跑、服务器报错”上八成都是系统依赖没装全。3.2 一个最小可运行的渲染脚本下面这段代码是我自己常用的一个最小骨架用 Playwright 驱动逻辑很直白打开页面、注入虚拟时钟、逐帧推进、截图、交给 FFmpeg 编码。const { chromium } require(playwright); const { spawn } require(child_process); const fs require(fs); async function renderHtmlToVideo(htmlPath, outputPath, options {}) { const fps options.fps || 30; const duration options.duration || 5; // 秒 const width options.width || 1080; const height options.height || 1920; const totalFrames fps * duration; const browser await chromium.launch({ headless: true }); const page await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1, }); await page.goto(file://${htmlPath}); await page.waitForLoadState(networkidle); // 注入虚拟时钟接管所有时间相关 API await page.evaluate(() { window.__virtualTime 0; const originalRAF window.requestAnimationFrame; window.requestAnimationFrame (cb) { return originalRAF(() cb(window.__virtualTime)); }; window.performance.now () window.__virtualTime; window.Date.now () window.__virtualTime; }); // 启动 FFmpeg 编码进程从 stdin 读取帧 const ffmpeg spawn(ffmpeg, [ -y, -f, image2pipe, -framerate, String(fps), -i, -, -c:v, libx264, -pix_fmt, yuv420p, -preset, medium, -crf, 18, outputPath, ]); for (let i 0; i totalFrames; i) { const timeMs (i / fps) * 1000; await page.evaluate((t) { window.__virtualTime t; // 触发所有注册的动画回调 window.dispatchEvent(new CustomEvent(virtual-tick, { detail: t })); }, timeMs); const buffer await page.screenshot({ type: png }); ffmpeg.stdin.write(buffer); } ffmpeg.stdin.end(); await new Promise((resolve) ffmpeg.on(close, resolve)); await browser.close(); } renderHtmlToVideo(./template.html, ./output.mp4, { fps: 30, duration: 5, width: 1080, height: 1920, });这段代码里有几个地方值得展开说。第一deviceScaleFactor我设成了 1因为视频分辨率是固定的不需要再乘设备像素比设成 2 反而会让截图变大、拖慢速度。第二虚拟时钟的注入必须在页面加载完成之后、动画开始之前否则页面里已经启动的动画会脱离控制。第三FFmpeg 的-crf 18是一个质量参数数值越小质量越高、文件越大18 到 23 之间是比较常用的区间我一般用 18 保证画质。3.3 让动画听虚拟时钟的话上面那段脚本有个前提你的 HTML 里的动画必须能被虚拟时钟驱动。如果你直接用 CSS 的animation属性浏览器会按真实时间跑虚拟时钟管不住它。解决办法有两个。第一个办法是用 CSS 变量控制动画进度。把动画定义成基于某个变量的函数然后每帧更新这个变量。比如.box { transform: translateX(calc(var(--progress) * 500px)); opacity: calc(var(--progress) * 1); }然后在 JS 里每帧设置document.documentElement.style.setProperty(--progress, value)。这样动画的进度就完全由你控制了。第二个办法是用 Web Animations API它允许你创建动画后手动设置currentTime。这个 API 的好处是性能好、控制精确缺点是写法比 CSS 稍微啰嗦一点。我一般推荐复杂动画用 Web Animations API简单动画用 CSS 变量两者可以混用。注意如果你用了第三方动画库比如 GSAP、Anime.js一定要确认它是否支持外部时钟驱动。GSAP 有gsap.ticker可以接管Anime.js 支持update手动推进。如果库不支持那它在逐帧渲染场景下基本没法用动画会乱掉。4. 性能优化与常见问题排查实录4.1 渲染速度上不去先看这三个瓶颈很多人第一次跑通流水线之后会发现怎么这么慢渲染 10 秒的视频要等好几分钟。这很正常因为逐帧渲染本质上是把实时渲染的活儿拆成了 N 倍的工作量。但慢归慢还是有优化空间的。我总结下来瓶颈通常出在三个地方。第一个瓶颈是截图本身。page.screenshot()每次都要把整个页面重新光栅化一遍这个开销不小。优化思路是尽量缩小截图区域只截需要的那部分而不是整个视口。另外PNG 编码比 JPEG 慢很多如果画质要求不是极致用 JPEG 能快不少。第二个瓶颈是进程间通信。截图数据要从浏览器进程传到 Node 进程再传给 FFmpeg 进程中间有两次序列化和管道传输。如果帧很大这个开销很可观。优化办法是让浏览器直接把帧写到 FFmpeg 的 stdin或者用共享内存。不过这个优化实现起来比较复杂一般项目用不上。第三个瓶颈是编码。libx264 的preset参数对速度影响巨大。medium是默认值veryfast能快一倍以上画质损失在可接受范围内。如果只是内部预览用ultrafast都行。真正要发布的时候再换成慢速预设重新编码。4.2 常见问题速查表下面这张表是我在实际项目中遇到过的典型问题整理出来方便你对照排查现象可能原因排查方向解决办法视频播放时绿屏像素格式不兼容检查 FFmpeg 参数加-pix_fmt yuv420p动画抖动、跳帧虚拟时钟未完全接管检查是否有动画走真实时间劫持所有时间 API文字显示成方块服务器缺少字体检查系统字体列表安装中文字体包音画不同步音频和视频分开编码检查时间基是否一致统一用同一时间基渲染中途卡死内存泄漏或死锁监控内存占用分批渲染及时释放输出文件过大码率或 CRF 设置过高检查编码参数调高 CRF 或降分辨率首帧黑屏页面未加载完成就截图检查等待逻辑等待 networkidle 和字体加载4.3 几个只有踩过才知道的坑说几个文档里不会写、但实际一定会遇到的问题。字体加载的坑。网页里用font-face加载自定义字体时浏览器是异步加载的。如果你在字体加载完成前就开始截图前几帧的文字会用 fallback 字体渲染导致视频开头文字样式突变。解决办法是在渲染前显式等待document.fonts.ready确保所有字体都加载完毕。透明背景的坑。如果你的 HTML 背景是透明的截图出来会是透明 PNG编码成 MP4 之后透明区域会变成黑色。如果你需要透明背景的视频得用支持 alpha 通道的编码格式比如 VP9 的 yuva420p但兼容性会差很多。大多数场景下直接给个纯色背景更省事。长视频内存的坑。如果你一次性渲染很长的视频帧数据堆积在内存里会导致内存爆掉。解决办法是流式处理渲染一帧就立刻喂给编码器不要攒着。上面那段示例代码就是流式的但如果你用的是“先全部截图再编码”的思路长视频一定会出问题。并发渲染的坑。想通过开多个浏览器实例来加速可以但要注意每个实例都会占用大量内存和 CPU。我一般把并发数控制在 CPU 核心数的一半左右再多反而会因为资源竞争变慢。而且 FFmpeg 编码本身也吃 CPU渲染和编码抢资源是常态做好限流很重要。5. 这套思路还能怎么扩展把 HTML 渲染成视频这件事本身只是一个基础能力。真正有意思的是它打开的那扇门。我自己在用的几个扩展方向分享出来给你参考。第一个方向是模板参数化。把视频模板做成一个带占位符的 HTML 文件然后用数据去填充。比如一个商品视频模板里面有{{productName}}、{{price}}、{{mainImage}}这些占位符渲染前用真实数据替换掉就能批量产出成百上千条视频。这套逻辑和前端做服务端渲染SSR几乎一模一样前端同学上手零成本。第二个方向是和 AI 生成结合。现在大模型生成 HTML 的能力已经相当不错了你可以让模型根据一段文案直接生成一个带动画的 HTML 页面然后丢进渲染流水线出视频。这就把“文案到视频”的链路彻底打通了。我试过用这种方式做知识科普类的短视频从一段文字到成片全程不需要人工干预效率提升非常明显。第三个方向是实时预览。渲染之前用户肯定想先看看效果。你可以把同一套 HTML 直接嵌到网页里做实时预览用户调整参数时页面实时更新确认无误后再走渲染流水线出 MP4。预览和最终输出用的是同一套代码所见即所得不会出现“预览好看、导出翻车”的情况。第四个方向是多分辨率适配。同一套 HTML通过改变视口尺寸和缩放比例可以一次性输出竖屏、横屏、方形等多种规格适配不同平台。这个用 CSS 的媒体查询或者容器查询就能实现不需要维护多套模板。说到底这套引擎最聪明的地方是它没有试图去替代现有的视频制作工具而是把“网页”这个已经被验证了无数次的渲染载体重新用在了视频生产上。你不需要学新的东西你会的 HTML、CSS、JS 就是你的视频制作语言。对于前端出身的人来说这几乎是把技能树直接平移到了视频领域。我在实际使用中最大的体会是当你把视频当成一个可以被代码精确控制的产物时很多以前觉得麻烦的事情——批量、参数化、自动化——突然就变得顺理成章了。如果你也在做内容批量生产相关的事情强烈建议花点时间把这套流水线跑通后面能省下的重复劳动远超你投入的学习成本。