
1. 从 hyperframes 说起一个被低估的 HTML 转 MP4 思路第一次看到 hyperframes 这个词是在一个做自动化内容分发的群里。有人丢了个链接说“这玩意儿能把 HTML 直接变成 MP4不用打开浏览器录屏”。当时我的第一反应是又是一个套壳 ffmpeg 的玩具。但真正跑起来之后我发现它的思路和市面上大多数“HTML 转视频”方案不太一样——它不是靠录屏也不是靠截图拼接而是把 HTML 页面当成一个可编程的动画时间轴来驱动逐帧渲染再合成视频。这个区别很关键。录屏方案的问题在于帧率不稳定、受系统负载影响大、无法精确控制每一帧的内容截图拼接方案则是帧与帧之间容易出现撕裂或跳变。hyperframes 走的是“声明式动画 确定性渲染”的路线你写的是 HTML/CSS/JS它负责在无头环境里按固定时间步长推进动画状态然后把每一帧的画面抓出来最后用编码器压成 MP4。整个过程是确定性的同一份代码跑两次出来的视频理论上是一模一样的。这套东西适合谁我梳理了一下大概三类人用得上一是做数据可视化报告的人想把 ECharts 或 D3 的图表导出成视频发给客户二是做社交媒体内容的人想批量生成带动态文字的短视频三是做自动化测试或监控的人想把页面状态变化录成可回放的视频证据。如果你属于这三类hyperframes 值得花时间研究一下。它不是什么万能工具但在“HTML 到 MP4”这条链路上它把可控性和自动化程度拉到了一个比较舒服的位置。2. 核心机制拆解hyperframes 到底怎么把网页变成视频2.1 渲染管线从 DOM 到像素到 H.264要理解 hyperframes 的工作原理得先搞清楚一条完整的渲染管线。它大致分成四个阶段页面加载与初始化、时间轴推进与状态更新、逐帧捕获、编码合成。页面加载阶段hyperframes 会启动一个无头浏览器实例通常是 Chromium 的 headless 模式把你的 HTML 文件加载进去等待所有资源就绪。这里有个细节它不会等window.onload就立刻开始而是会额外等待一个可配置的settleTime确保字体、图片、异步数据都到位。这个参数默认是 500ms但在实际使用中我建议根据页面复杂度调到 1000ms 以上尤其是用了 web font 或远程 API 的页面。时间轴推进阶段是 hyperframes 最核心的部分。它维护一个虚拟时钟按照你设定的fps帧率和duration总时长计算出总帧数然后逐帧推进。每一帧它会把当前时间戳注入到页面中触发你预先注册的动画回调。这些回调可以是 CSS 动画的animation-delay控制也可以是 JS 里手动更新 DOM 属性的函数。关键在于所有动画状态都必须由这个时间戳驱动而不能依赖requestAnimationFrame或Date.now()否则渲染结果就不可复现。逐帧捕获阶段hyperframes 调用浏览器的截图接口通常是Page.captureScreenshot或screenshot方法把当前视口的内容抓成一张位图。这里有个性能考量如果每一帧都走完整的 PNG 编码再传给编码器开销会很大。所以实际实现中它一般会直接把原始像素数据RGBA buffer传给 ffmpeg 的 stdin跳过中间的文件落盘环节。编码合成阶段就是 ffmpeg 的活了。hyperframes 会把原始帧数据通过管道喂给 ffmpeg指定输入格式为rawvideo像素格式为rgba然后由 ffmpeg 完成 H.264 编码和 MP4 封装。整个管线的瓶颈通常在截图这一步因为无头浏览器的截图操作涉及 GPU 回读速度受限于显存带宽。2.2 时间轴驱动为什么不能用 requestAnimationFrame这个问题我被问过很多次。很多人第一反应是我在页面里用requestAnimationFrame写动画然后录屏不就行了为什么非要搞一套时间轴驱动原因在于确定性。requestAnimationFrame的回调时机取决于浏览器的刷新率、系统负载、甚至当前标签页是否可见。你在本地跑的时候可能是稳定的 60fps但到了 CI 环境里可能掉到 30fps 甚至更低。更麻烦的是requestAnimationFrame传递的时间戳是相对于页面加载时刻的而 hyperframes 需要的是相对于视频起始时刻的。这两个时间基准不一致就会导致动画和视频时间轴对不上。hyperframes 的做法是在页面里注入一个全局的window.__hyperframes_time变量每一帧渲染前更新这个值然后你的动画代码读取这个变量来决定当前状态。比如你要做一个 3 秒内从左到右移动的方块代码大概长这样function renderFrame(t) { const progress Math.min(t / 3000, 1); const x progress * 800; document.getElementById(box).style.transform translateX(${x}px); } window.__hyperframes_onFrame renderFrame;hyperframes 在每一帧推进时会先更新t然后调用window.__hyperframes_onFrame(t)再截图。这样无论实际渲染耗时多少每一帧对应的逻辑时间都是精确的。即使某一帧渲染花了 200ms下一帧的时间戳依然会按1000/fps的步长前进不会累积误差。注意如果你的动画里用了 CSS transition 或 animation一定要把它们禁用掉改用 JS 手动控制。因为 CSS 动画的时间基准是真实时钟不受__hyperframes_time影响会导致画面和视频时间轴脱节。2.3 帧率与时长参数选择背后的计算逻辑帧率和时长的选择不是拍脑袋定的它直接影响到输出文件的大小、渲染耗时和观感流畅度。我整理了一个对照表方便你根据场景快速决策场景推荐帧率推荐时长理由数据图表动画24fps5-10s图表变化不需要高帧率24fps 足够流畅且文件小文字滚动/字幕30fps10-30s文字移动对帧率敏感30fps 是观感底线复杂 UI 交互演示30-60fps15-60s交互细节多需要高帧率捕捉过渡效果静态画面淡入淡出15fps3-5s画面变化少低帧率不影响观感渲染耗时的估算公式大概是总耗时 ≈ 总帧数 × 单帧截图耗时 编码耗时。单帧截图耗时在无头 Chromium 上通常是 30-80ms取决于页面复杂度和视口大小。假设你做一个 30fps、20 秒的视频总帧数 600 帧单帧 50ms光截图就要 30 秒。再加上编码整体渲染时间可能在 40-60 秒左右。这个时间在 CI 环境里是可以接受的但如果你要批量生成上百个视频就得考虑并行化了。文件大小方面H.264 在 CRF 23 下的码率大约是每像素每帧 0.1-0.2 bit。一个 1920×1080 的视频30fps码率大概在 6-12 Mbps。20 秒的视频文件大小约 15-30 MB。如果你要控制文件大小可以调高 CRF 值比如 28或者降低分辨率。3. 实操全流程从零跑通一个 hyperframes 项目3.1 环境准备与依赖安装hyperframes 本身是一个 CLI 工具但它的运行依赖几个外部组件。我建议在 Ubuntu 20.04 或 22.04 上操作macOS 也可以但偶尔会有字体渲染差异。Windows 的话建议走 WSL2原生 Windows 的支持不太稳定。首先确认 Node.js 版本不低于 18然后安装 hyperframes CLInpm install -g hyperframes-cli安装完成后还需要确保系统里有 ffmpeg 和 Chromium。ffmpeg 用于编码Chromium 用于渲染。在 Ubuntu 上可以这样装sudo apt update sudo apt install -y ffmpeg chromium-browser如果你不想用系统 Chromiumhyperframes 也支持通过 Puppeteer 自动下载一个匹配版本的 Chromium。这种情况下你只需要装 ffmpeg 就行。我个人的习惯是用系统 Chromium因为启动速度更快而且方便调试。验证安装是否成功hyperframes --version ffmpeg -version chromium-browser --version三个命令都能正常输出版本号环境就算就绪了。3.2 项目结构与配置文件详解hyperframes 项目的典型结构是这样的my-video/ ├── hyperframes.config.json ├── src/ │ ├── index.html │ ├── style.css │ └── animation.js └── assets/ └── logo.png核心配置文件hyperframes.config.json控制着整个渲染过程。一个完整的配置大概长这样{ entry: src/index.html, output: output/video.mp4, width: 1920, height: 1080, fps: 30, duration: 15000, settleTime: 1000, crf: 23, preset: medium, pixelFormat: yuv420p, backgroundColor: #ffffff }这里有几个参数值得展开说。duration的单位是毫秒15000 就是 15 秒。settleTime是页面加载后额外等待的时间前面提过复杂页面要调大。crf是 H.264 的质量参数范围 0-51数值越小质量越高文件越大23 是默认值18 接近视觉无损。preset控制编码速度可选ultrafast到veryslow越慢压缩率越高。pixelFormat设为yuv420p是为了兼容大多数播放器如果你不需要兼容性可以设yuv444p获得更好的色彩。提示backgroundColor在页面本身没有设置背景色时生效。如果你的 HTML 里已经设了body { background: #000; }这个配置会被覆盖。3.3 编写可被 hyperframes 驱动的 HTML 动画这一步是整个流程里最需要动脑子的地方。你不能直接拿一个普通的网页就丢给 hyperframes必须按照它的时间轴约定来写动画逻辑。先看 HTML 骨架!DOCTYPE html html langzh-cn head meta charsetutf-8 titleHyperframes Demo/title link relstylesheet hrefstyle.css /head body div idstage div idtitle数据报告/div div idbar/div /div script srcanimation.js/script /body /htmlCSS 里只写静态样式所有动态效果都留给 JS* { margin: 0; padding: 0; box-sizing: border-box; } body { width: 1920px; height: 1080px; background: #0f172a; font-family: sans-serif; overflow: hidden; } #stage { position: relative; width: 100%; height: 100%; } #title { position: absolute; top: 200px; left: 160px; font-size: 72px; color: #e2e8f0; opacity: 0; } #bar { position: absolute; bottom: 300px; left: 160px; width: 0; height: 80px; background: linear-gradient(90deg, #38bdf8, #818cf8); border-radius: 8px; }animation.js 是核心它注册一个逐帧回调const title document.getElementById(title); const bar document.getElementById(bar); window.__hyperframes_onFrame function(t) { // 标题淡入0-800ms const titleProgress Math.min(t / 800, 1); title.style.opacity titleProgress; title.style.transform translateY(${(1 - titleProgress) * 40}px); // 进度条展开800-3000ms const barProgress Math.max(0, Math.min((t - 800) / 2200, 1)); bar.style.width ${barProgress * 1200}px; };这段代码的逻辑很直白t是当前帧的时间戳毫秒根据t计算每个元素的进度然后更新样式。注意所有动画都是幂等的——给定同一个t渲染结果永远一样。这是 hyperframes 确定性的基础。3.4 渲染执行与输出验证配置和代码都写好之后在项目根目录执行hyperframes renderCLI 会输出渲染进度大概长这样[hyperframes] Loading page: src/index.html [hyperframes] Waiting for settle: 1000ms [hyperframes] Rendering 450 frames at 30fps... [hyperframes] Frame 100/450 (22%) [hyperframes] Frame 200/450 (44%) [hyperframes] Frame 300/450 (67%) [hyperframes] Frame 400/450 (89%) [hyperframes] Encoding with ffmpeg... [hyperframes] Done. Output: output/video.mp4渲染完成后用 ffprobe 验证一下输出文件ffprobe -v error -show_entries streamwidth,height,r_frame_rate,duration -of defaultnoprint_wrappers1 output/video.mp4正常输出应该是width1920 height1080 r_frame_rate30/1 duration15.000000如果帧率或时长对不上大概率是duration配置和实际帧数计算有偏差。hyperframes 内部会做一次Math.round(duration / 1000 * fps)来算总帧数所以 15000ms 在 30fps 下是 450 帧时长正好 15 秒。如果你设了 15500ms总帧数会变成 465 帧实际时长是 15.5 秒不会有累积误差。4. 踩坑实录hyperframes 使用中的典型问题与排查4.1 画面闪烁与帧间不一致这是最常见的问题。表现是输出的视频里某些帧突然变暗、变亮或者元素位置跳变。根本原因通常是页面里存在不受时间轴控制的异步更新。我遇到过一次典型情况页面里用了一个第三方图表库它在初始化时会启动自己的动画循环。虽然我在__hyperframes_onFrame里手动设置了图表数据但图表库内部的过渡动画还在跑导致每一帧截到的画面都是“过渡中的中间态”而不是我期望的最终态。解决办法是找到图表库的动画开关把它关掉。以 ECharts 为例在setOption时加上animation: falsechart.setOption(option, { animation: false });另一个常见原因是字体加载。如果页面用了 web font而字体文件在截图开始时还没加载完前几帧的文字会用 fallback 字体渲染后面字体加载完了又变成正确字体画面就会跳。解决办法是把settleTime调大或者用document.fonts.ready显式等待window.__hyperframes_ready document.fonts.ready;hyperframes 会等待这个 Promise resolve 之后再开始渲染。4.2 渲染速度过慢的优化策略前面算过600 帧的视频可能要跑 40-60 秒。如果你要批量生成这个速度就有点难受了。我试过几种优化手段效果比较明显的有三个。第一是降低视口分辨率。1920×1080 的截图耗时大约是 1280×720 的 2.2 倍。如果最终输出不需要全高清可以在配置里把width和height减半渲染完再用 ffmpeg 放大。虽然会损失一些细节但对于文字为主的视频来说完全够用。第二是复用浏览器实例。默认情况下 hyperframes 每次渲染都会启动一个新的 Chromium 进程启动开销大概 1-2 秒。如果你要连续渲染多个视频可以用--reuse-browser参数让多个任务共享同一个实例。不过要注意共享实例时页面之间的状态可能会互相污染建议每个视频渲染前都执行一次page.reload()。第三是并行渲染。hyperframes 本身不支持多进程并行但你可以用 shell 脚本把多个项目分配到不同的 CPU 核心上同时跑。比如hyperframes render --config project-a.json hyperframes render --config project-b.json hyperframes render --config project-c.json wait在 8 核机器上同时跑 3-4 个渲染任务是性价比比较高的选择。再多的话内存和 GPU 回读带宽会成为瓶颈。4.3 输出文件兼容性问题有时候渲染出来的 MP4 在本地播放器能放但传到某些平台就提示格式不支持。这通常是编码参数的问题。最常见的原因是pixelFormat设成了yuv444p或yuv420p10le这些格式在部分播放器和平台上不被支持。注意如果你要上传到主流视频平台务必使用yuv420p像素格式和H.264 High Profile。可以在配置里加上profile: high, level: 4.1。另一个坑是音频轨缺失。hyperframes 默认只输出视频轨没有音频。如果你需要背景音乐得在渲染完成后用 ffmpeg 单独合并ffmpeg -i video.mp4 -i bgm.mp3 -c:v copy -c:a aac -shortest output-with-audio.mp4注意-c:v copy表示视频流直接复制不重新编码这样速度快且不会损失画质。-shortest确保输出时长以较短的流为准。4.4 常见问题速查表现象可能原因排查方法解决方案画面闪烁异步动画未禁用检查第三方库的 animation 配置关闭所有非时间轴驱动的动画文字字体跳变web font 未加载完在页面里打印document.fonts.status增大 settleTime 或等待 fonts.ready渲染卡在某一帧页面里有死循环或阻塞用--debug模式查看当前帧号检查 JS 里是否有同步阻塞操作输出视频无声音默认不包含音频轨用 ffprobe 查看 stream 信息渲染后用 ffmpeg 合并音频文件体积过大CRF 值太低或分辨率过高用 ffprobe 查看码率调高 CRF 到 26-28 或降低分辨率颜色偏暗色彩空间转换问题对比源页面和输出视频的截图确保 pixelFormat 为 yuv420p检查 colorRange5. 进阶玩法把 hyperframes 接入自动化流水线5.1 与 CI/CD 集成批量生成视频hyperframes 最大的价值在于它可以完全无人值守地运行。我现在的做法是把视频模板做成一个 Git 仓库数据通过环境变量或 JSON 文件注入每次数据更新就触发 CI 流水线重新渲染。以 GitLab CI 为例.gitlab-ci.yml大概长这样render-video: stage: build image: node:18 before_script: - apt-get update apt-get install -y ffmpeg chromium - npm install -g hyperframes-cli script: - hyperframes render --config config/prod.json artifacts: paths: - output/video.mp4 expire_in: 7 days这个流水线每次跑大概 2-3 分钟其中大部分时间花在 Chromium 启动和截图渲染上。如果你们的 CI runner 性能一般可以考虑把渲染任务放到专门的渲染机上CI 只负责触发和收集结果。5.2 动态数据注入的几种方式视频内容需要随数据变化时有几种注入方式可选。最简单的是环境变量注入在 HTML 里用占位符渲染前用脚本替换。比如div idtitle{{TITLE}}/div然后在渲染前执行sed -i s/{{TITLE}}/$VIDEO_TITLE/g src/index.html hyperframes render这种方式简单粗暴但只适合纯文本替换。如果数据结构复杂建议用JSON 数据文件 fetch的方式fetch(./data.json) .then(res res.json()) .then(data { document.getElementById(title).textContent data.title; window.__hyperframes_ready Promise.resolve(); });hyperframes 会等待__hyperframes_readyresolve 之后再开始渲染这样就能确保数据加载完成后再截图。第三种方式是通过 CLI 参数传递。hyperframes 支持--define参数注入全局变量hyperframes render --define TITLE季度报告 --define VALUE42在页面里通过window.__hyperframes_defines.TITLE读取。这种方式最适合少量参数的场景。5.3 从 MP4 到其他格式的扩展hyperframes 输出的是 MP4但有时候你需要 GIF 或 WebM。这时候不需要重新渲染直接用 ffmpeg 转换就行。转 GIFffmpeg -i video.mp4 -vf fps15,scale640:-1:flagslanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse -loop 0 output.gif这条命令做了几件事把帧率降到 15fps 减小体积缩放到 640 宽然后用调色板优化 GIF 色彩。split和palettegen/paletteuse是生成高质量 GIF 的标准做法比直接转出来的效果好很多。转 WebMffmpeg -i video.mp4 -c:v libvpx-vp9 -crf 30 -b:v 0 output.webmVP9 在同等画质下比 H.264 体积小 30% 左右但编码速度慢不少。如果只是偶尔转一次这点时间可以接受。6. 一些个人体会和实用建议hyperframes 这个工具我用了大概半年踩过的坑基本都写在上面了。最后再分享几个零碎但实用的经验。第一永远在配置里显式指定width和height。不要依赖页面的 CSS 尺寸因为无头浏览器的默认视口可能和你预期的不一样。我吃过一次亏本地跑出来是 1920×1080到了 CI 环境变成了 800×600原因是 CI 的 Chromium 没有读取到 CSS 里的body尺寸。显式指定之后就没再出过问题。第二动画逻辑尽量用纯函数写。所谓纯函数就是给定t输出确定的样式不依赖任何外部状态。这样做的好处是你可以单独测试每一帧的渲染结果而不用跑完整的渲染流程。我通常会写一个renderFrame(t)函数然后在浏览器控制台里手动调用它来检查各个时间点的画面。第三保留一份低分辨率的预览配置。正式渲染前先用 640×360、15fps 跑一遍几秒钟就能出结果快速确认动画节奏和内容是否正确。确认无误后再用全分辨率渲染。这个习惯帮我省了很多等待时间。第四注意 Chromium 版本差异。不同版本的 Chromium 在字体渲染、CSS 支持、截图行为上都可能有细微差别。如果你的项目需要在多台机器上渲染建议锁定 Chromium 版本或者直接用 Puppeteer 自带的版本避免“本地能跑 CI 跑不了”的尴尬。这套流程跑通之后你会发现 HTML 到 MP4 的转换其实没有想象中那么复杂。关键是把时间轴驱动的思路理解透剩下的就是调参数和踩坑了。