1. 从 hyperframes 说起:一个被低估的 HTML 视频生成思路
第一次看到 hyperframes 这个词,我脑子里蹦出来的不是某个具体工具,而是一种做法:用 HTML 写画面,用 CLI 驱动流程,让 AI coding agents 去填内容,最后吐出一个 MP4。这套组合听起来像是把前端、命令行和视频编码硬凑在一起,但真正用过之后你会发现,它解决的恰恰是传统视频制作里最烦人的那部分——重复劳动和模板化生产。
hyperframes 的核心价值在于把“帧”这件事抽象成了 HTML 结构。你不需要打开 Premiere 或者 After Effects,不需要拖时间轴,不需要手动对齐每一个元素。你写一个<!doctype html><html lang="zh-cn"><head><meta charset="utf-8">开头的页面,用 CSS 控制布局和动画,用 JS 控制时间线,然后通过 CLI 工具把这个页面逐帧渲染成图片序列,最后用 ffmpeg 合成 MP4。整个过程可以完全脚本化,可以塞进 CI/CD,可以让 AI coding agents 批量生成不同版本的画面。
适合谁来参考?三类人最受益。第一类是前端开发者,你们本来就会写 HTML/CSS/JS,现在只是把浏览器当成渲染引擎,把网页当成视频画布。第二类是做自动化内容生产的人,比如需要批量生成产品演示、数据可视化视频、社交媒体短视频的团队。第三类是在折腾 AI coding agents 的人,你们可以用 codex cli、zcode cli、claude code 这类工具去生成 HTML 模板,再通过 CLI 管道自动渲染,实现“描述即视频”的雏形。
我实测下来,这套方案最舒服的地方是调试成本极低。传统视频软件里改一个字体大小要重新渲染预览,而在 hyperframes 流程里,你直接在浏览器里刷新就能看到效果,确认后再跑 CLI 批量输出。这个反馈闭环的速度差,决定了你一天能迭代多少个版本。
2. 整体设计思路:为什么用 HTML 当视频描述语言
2.1 把浏览器当成渲染引擎的合理性
视频本质上就是一系列静态画面的快速切换。传统做法是用专业软件在时间轴上摆放图层,而 hyperframes 的思路是:既然浏览器已经能精确渲染 HTML/CSS/JS,并且支持动画、过渡、变换、滤镜,那为什么不直接让浏览器输出每一帧?
这个选择背后有几个硬逻辑。第一,HTML/CSS 的布局能力极强,flex、grid、绝对定位、transform,这些用来做视频画面绰绰有余。第二,CSS animation 和 Web Animations API 可以精确控制时间曲线,@keyframes配合animation-delay能做出复杂的时序效果。第三,浏览器的字体渲染、颜色管理、阴影和渐变都已经非常成熟,你不需要额外处理这些底层细节。
更重要的是,HTML 是文本格式。文本意味着可以被 AI coding agents 直接生成、修改、版本控制。你可以让 codex cli 根据一段描述生成一个 HTML 模板,然后人工微调,再通过 CLI 渲染成 MP4。整个链路里,唯一需要“人”介入的就是审美判断,而不是机械操作。
2.2 CLI 驱动的流水线设计
hyperframes 的 CLI 部分是整个流程的骨架。一个典型的流水线长这样:输入一个 HTML 文件或者一个包含多个 HTML 片段的目录,CLI 启动无头浏览器,设置视口尺寸和帧率,逐帧截图,输出 PNG 序列,最后调用 ffmpeg 合成 MP4。
为什么用 CLI 而不是 GUI?因为 CLI 可以批处理。你可以写一个 shell 脚本,遍历一个 CSV 文件,每一行生成一个 HTML 变量,渲染出不同的视频版本。这种批量能力在 GUI 里几乎不可能实现,但在 CLI 里就是几行代码的事。
我自己的做法是:用 Node.js 写一个入口脚本,读取配置 JSON,里面定义分辨率、帧率、时长、HTML 模板路径、输出文件名。然后调用 Puppeteer 或者 Playwright 打开页面,用page.evaluate()控制动画进度,用page.screenshot()逐帧捕获。最后用child_process.exec()调用 ffmpeg 合成。整个脚本不到 200 行,但能覆盖 90% 的常见需求。
2.3 AI coding agents 在链路中的位置
AI coding agents 在这里不是噱头,而是实打实的生产力工具。你可以把 hyperframes 的 HTML 模板当成一种“领域特定语言”,让 AI 去生成和修改。比如你告诉 codex cli:“生成一个 1080x1920 的竖屏 HTML,背景是深色渐变,中间有一个标题从下方滑入,底部有一个进度条动画,总时长 5 秒。”它就能给你一个可用的 HTML 文件。
但这里有个关键点:AI 生成的 HTML 往往在动画时序上不够精确。我的经验是,让 AI 生成结构和样式,然后自己手动调整@keyframes的百分比和animation-duration。这样分工效率最高,AI 负责“从无到有”,人负责“从有到精”。
另外,claude code 和 codex cli 这类工具在调试时特别有用。当渲染出来的 MP4 和预期不符时,你可以把 HTML 片段和错误现象贴给 AI,让它分析可能是哪个 CSS 属性或者 JS 逻辑出了问题。这比你自己一行行排查快得多。
3. 核心细节解析:HTML 转 MP4 的关键技术点
3.1 视口设置与分辨率匹配
这是最容易踩坑的地方。浏览器的视口尺寸必须和最终视频分辨率完全一致,否则会出现缩放模糊或者黑边。比如你要输出 1920x1080 的 MP4,那么 Puppeteer 的viewport必须设置为{ width: 1920, height: 1080, deviceScaleFactor: 1 }。
deviceScaleFactor这个参数特别重要。如果你设置为 2,浏览器会以两倍像素密度渲染,截图出来是 3840x2160,然后 ffmpeg 再缩放到 1920x1080,画质会更好,但渲染时间翻倍。我的建议是:如果视频里有大量文字和小元素,用deviceScaleFactor: 2再缩放,文字边缘会平滑很多。如果只是大色块和简单图形,deviceScaleFactor: 1就够了。
还有一个细节:CSS 里的body默认有 margin,一定要在样式里写body { margin: 0; padding: 0; overflow: hidden; },否则截图边缘会出现白边。overflow: hidden也很关键,防止滚动条出现影响画面。
3.2 帧率控制与时间线同步
帧率决定了视频的流畅度。24fps 是电影感,30fps 是标准,60fps 是丝滑。但帧率越高,渲染时间越长。我一般用 30fps 做大部分内容,只有需要慢动作或者精细动画时才上 60fps。
时间线同步是 hyperframes 里最需要动脑子的部分。你不能依赖requestAnimationFrame的实时播放,因为无头浏览器的渲染速度不稳定。正确做法是:用page.evaluate()手动设置当前时间,然后触发对应的动画状态。
具体来说,你可以用 Web Animations API 的animation.currentTime属性。在 Puppeteer 里,每一帧执行一次page.evaluate((t) => { document.getAnimations().forEach(a => a.currentTime = t); }, frameIndex * (1000 / fps))。这样每一帧的动画状态都是确定的,不会因为渲染速度波动而错位。
如果你用的是 CSS animation 而不是 Web Animations API,那就需要把animation-play-state设为paused,然后通过修改animation-delay的负值来“倒带”。这个方法比较 hack,但兼容性好。我一般推荐直接用 Web Animations API,控制更精确。
3.3 截图序列的命名与合成
截图序列的命名必须有序,否则 ffmpeg 合成时会乱序。标准做法是用frame_%06d.png这样的格式,从frame_000001.png开始递增。ffmpeg 的命令是:
ffmpeg -framerate 30 -i frame_%06d.png -c:v libx264 -pix_fmt yuv420p -crf 18 output.mp4这里有几个参数值得解释。-framerate 30是输入帧率,必须和截图时的帧率一致。-c:v libx264是 H.264 编码,兼容性最好。-pix_fmt yuv420p是像素格式,确保在大多数播放器上能正常显示。-crf 18是质量参数,范围 0-51,数值越小质量越高,18 算是视觉无损。
如果你需要 H.265 压缩,把libx264换成libx265,但要注意兼容性会下降,部分老设备可能不支持。我一般输出两个版本:H.264 用于通用分发,H.265 用于存档。
3.4 音频轨道的处理
hyperframes 默认只处理视频轨道。如果你需要加背景音乐或者旁白,有两种做法。第一种是在 ffmpeg 合成时直接混入音频文件:
ffmpeg -framerate 30 -i frame_%06d.png -i audio.mp3 -c:v libx264 -c:a aac -shortest output.mp4-shortest确保视频和音频长度一致,以较短的为准。第二种做法是在 HTML 里用<audio>标签,然后通过 Puppeteer 的page.evaluate()控制播放进度。但这种方法在无头浏览器里不太稳定,我建议还是用 ffmpeg 后期混音。
注意:如果你用 ffmpeg 混音,音频的采样率要和视频帧率匹配。一般音频用 44100Hz 或 48000Hz,视频 30fps,ffmpeg 会自动处理重采样,但最好在命令里显式指定
-ar 48000避免意外。
4. 实操过程:从零搭建一个 hyperframes 渲染流水线
4.1 环境准备与依赖安装
先列一下我用的工具链。Node.js 18 以上,Puppeteer 或者 Playwright,ffmpeg 4.0 以上。如果你在 Ubuntu 上,ffmpeg 直接用apt install ffmpeg就行。Puppeteer 安装时会自动下载 Chromium,但国内网络可能慢,可以设置PUPPETEER_DOWNLOAD_HOST环境变量指向镜像。
初始化项目:
mkdir hyperframes-demo && cd hyperframes-demo npm init -y npm install puppeteer如果你用 Playwright,命令是npm install playwright,然后npx playwright install chromium。Playwright 的好处是自带更多浏览器内核,但体积也更大。我一般用 Puppeteer,够用。
ffmpeg 验证:
ffmpeg -version确保输出里有libx264和libx265。如果没有,可能需要重新编译或者安装完整版。
4.2 编写第一个 HTML 视频模板
创建一个template.html,内容如下:
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>hyperframes demo</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { width: 1920px; height: 1080px; overflow: hidden; background: linear-gradient(135deg, #1a1a2e, #16213e); font-family: 'Helvetica Neue', Arial, sans-serif; display: flex; align-items: center; justify-content: center; } .title { color: #fff; font-size: 120px; font-weight: 700; opacity: 0; transform: translateY(60px); animation: slideUp 1s ease-out forwards; } .subtitle { color: #8899aa; font-size: 48px; margin-top: 24px; opacity: 0; animation: fadeIn 1s ease-out 0.5s forwards; } @keyframes slideUp { to { opacity: 1; transform: translateY(0); } } @keyframes fadeIn { to { opacity: 1; } } </style> </head> <body> <div style="text-align: center;"> <div class="title">hyperframes</div> <div class="subtitle">HTML to MP4 pipeline</div> </div> </body> </html>这个模板定义了一个 1920x1080 的画布,标题从下方滑入,副标题延迟淡入。总时长 1.5 秒左右。
4.3 编写渲染脚本
创建render.js:
const puppeteer = require('puppeteer'); const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const FPS = 30; const DURATION = 2; // 秒 const WIDTH = 1920; const HEIGHT = 1080; const OUTPUT_DIR = './frames'; const OUTPUT_MP4 = './output.mp4'; async function render() { // 清理旧帧 if (fs.existsSync(OUTPUT_DIR)) { fs.rmSync(OUTPUT_DIR, { recursive: true }); } fs.mkdirSync(OUTPUT_DIR); const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setViewport({ width: WIDTH, height: HEIGHT, deviceScaleFactor: 1 }); await page.goto('file://' + path.resolve(__dirname, 'template.html')); // 暂停所有动画 await page.evaluate(() => { document.getAnimations().forEach(a => a.pause()); }); const totalFrames = FPS * DURATION; for (let i = 0; i < totalFrames; i++) { const time = (i / FPS) * 1000; await page.evaluate((t) => { document.getAnimations().forEach(a => { a.currentTime = t; }); }, time); const filename = path.join(OUTPUT_DIR, `frame_${String(i + 1).padStart(6, '0')}.png`); await page.screenshot({ path: filename }); if (i % 10 === 0) console.log(`Rendered frame ${i + 1}/${totalFrames}`); } await browser.close(); // 合成 MP4 execSync(`ffmpeg -y -framerate ${FPS} -i ${OUTPUT_DIR}/frame_%06d.png -c:v libx264 -pix_fmt yuv420p -crf 18 ${OUTPUT_MP4}`, { stdio: 'inherit' }); console.log('Done:', OUTPUT_MP4); } render().catch(console.error);运行node render.js,你会看到帧序列被逐张截图,然后 ffmpeg 合成 MP4。整个过程大概 10-20 秒,取决于机器性能。
4.4 参数计算与性能优化
渲染时间主要花在截图和编码上。截图速度取决于页面复杂度,编码速度取决于 CPU。我实测下来,1920x1080、30fps、2 秒的视频,在 M1 MacBook Air 上大约 8 秒完成,在 Ubuntu 虚拟机上大约 15 秒。
如果你要渲染更长的视频,比如 60 秒,帧数就是 1800 张,截图时间会线性增长。这时候可以考虑几个优化:第一,降低deviceScaleFactor到 1,减少像素量。第二,用page.screenshot({ type: 'jpeg', quality: 90 })输出 JPEG 而不是 PNG,文件更小,写入更快,但画质略有损失。第三,用 ffmpeg 的-threads参数开启多线程编码。
还有一个技巧:如果你的动画是循环的,可以只渲染一个循环周期,然后用 ffmpeg 的-stream_loop参数重复。比如:
ffmpeg -stream_loop 5 -framerate 30 -i frame_%06d.png -c:v libx264 -pix_fmt yuv420p output.mp4这样 2 秒的素材可以变成 10 秒,节省 80% 的渲染时间。
5. 常见问题与排查技巧实录
5.1 截图出现白边或者黑边
这是最常见的问题。原因通常是body的 margin 没有清零,或者视口尺寸和 CSS 尺寸不匹配。检查两点:第一,CSS 里有没有* { margin: 0; padding: 0; }。第二,page.setViewport的宽高是否和body的宽高一致。如果body设置了width: 100vw; height: 100vh;,那视口尺寸就是最终输出尺寸。
还有一种情况是deviceScaleFactor大于 1 时,截图尺寸会翻倍,但 ffmpeg 合成时没有缩放,导致画面只显示左上角。解决办法是在 ffmpeg 命令里加-vf scale=1920:1080强制缩放。
5.2 动画时序错乱或者跳帧
如果你用 CSS animation 而不是 Web Animations API,animation.currentTime可能不生效。这时候需要改用animation-delay的负值来控制进度。具体做法是:在每一帧执行document.querySelectorAll('*').forEach(el => { el.style.animationDelay =-${time}ms; })。但这个方法会触发重排,性能较差。
更好的方案是统一用 Web Animations API。如果你必须用 CSS animation,可以在页面加载后立即调用document.getAnimations()获取所有动画对象,然后逐个设置currentTime。注意,getAnimations()返回的动画对象在页面重绘后可能会变化,所以最好在每一帧都重新获取。
5.3 ffmpeg 合成报错 "No such file or directory"
检查帧文件的命名是否连续。ffmpeg 的%06d要求文件名从000001开始,中间不能有缺失。如果你的截图脚本因为异常中断,可能会留下不完整的序列。解决办法是重新渲染,或者在脚本里加异常处理,确保每一帧都成功写入。
另外,Windows 上路径分隔符是反斜杠,ffmpeg 命令里要用正斜杠或者双反斜杠。我建议统一用 Node.js 的path.join生成路径,然后在传给 ffmpeg 之前把反斜杠替换成正斜杠。
5.4 渲染出来的视频颜色偏暗或者偏灰
这是色彩空间的问题。浏览器渲染时用的是 sRGB,而 ffmpeg 默认可能用 BT.601 或者 BT.709。解决办法是在 ffmpeg 命令里显式指定色彩空间:
ffmpeg -framerate 30 -i frame_%06d.png -c:v libx264 -pix_fmt yuv420p -colorspace bt709 -color_primaries bt709 -color_trc bt709 -crf 18 output.mp4这样能确保颜色和浏览器里看到的一致。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 画面有白边 | body margin 未清零 | 添加* { margin: 0; padding: 0; } |
| 动画不播放 | 动画被暂停但未设置 currentTime | 用document.getAnimations()逐帧设置 |
| 视频模糊 | deviceScaleFactor 过低 | 提高到 2,或增加输出分辨率 |
| 合成报错 | 帧文件缺失或命名不连续 | 检查截图脚本异常处理 |
| 颜色偏暗 | 色彩空间不匹配 | ffmpeg 加-colorspace bt709 |
| 渲染太慢 | 分辨率过高或帧率过高 | 降低 deviceScaleFactor 或改用 JPEG |
| 音频不同步 | 音频采样率和视频帧率不匹配 | ffmpeg 加-ar 48000和-shortest |
提示:如果你在 CI/CD 里跑这个流程,记得给 Puppeteer 加
--no-sandbox和--disable-setuid-sandbox参数,否则在容器里会启动失败。另外,容器里需要安装 Chromium 的依赖库,Ubuntu 上可以用apt install -y libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libgbm1一次性装齐。
6. 进阶玩法:AI coding agents 与批量生产
6.1 用 codex cli 生成 HTML 模板
codex cli 这类工具最擅长的就是根据自然语言描述生成结构化代码。你可以这样用:
codex generate --prompt "生成一个 1080x1920 竖屏 HTML,深色背景,顶部有一个圆形头像占位,中间是三行文字依次淡入,底部有一个按钮样式的元素,总时长 6 秒,帧率 30fps" --output template.html生成的 HTML 可能不完美,但结构基本可用。你只需要调整@keyframes的时序和颜色值。我一般会让 AI 生成三版,然后挑一版最接近的,手动微调。
6.2 批量渲染不同数据版本
假设你要为 100 个产品生成演示视频,每个产品的名称、价格、卖点不同。你可以写一个 CSV 文件,然后用 Node.js 读取,替换 HTML 模板里的占位符,再逐个渲染。
const csv = fs.readFileSync('products.csv', 'utf-8').split('\n'); for (const line of csv) { const [name, price, feature] = line.split(','); let html = fs.readFileSync('template.html', 'utf-8'); html = html.replace('{{name}}', name).replace('{{price}}', price).replace('{{feature}}', feature); fs.writeFileSync('temp.html', html); // 调用渲染函数 await render('temp.html', `output_${name}.mp4`); }这样 100 个视频可以在无人值守的情况下跑完。我实测过,100 个 10 秒的视频,在 8 核机器上大约 40 分钟完成。
6.3 与现有工具链的集成
hyperframes 的产出是标准 MP4,可以无缝接入任何后续流程。比如你可以用 ffmpeg 把多个 MP4 拼接成一个长视频,或者用m3u8切片做流媒体分发。如果你需要把 MP4 转成其他格式,ffmpeg 几乎支持所有编解码器。
另外,如果你在 GitLab CI 里跑这个流程,可以用gitlab cli触发流水线,把渲染好的 MP4 作为 artifact 上传。这样每次代码提交都能自动生成最新的演示视频,特别适合产品文档或者营销素材的持续更新。
注意:批量渲染时要注意磁盘空间。100 个 10 秒的 1080p 视频,每个大约 5-10MB,总共 1GB 左右。如果分辨率更高或者时长更长,建议渲染完一个就清理帧序列,只保留最终 MP4。
7. 我踩过的坑和最后分享几个小技巧
第一个坑是字体加载。如果你在 HTML 里用了自定义字体,无头浏览器可能在字体加载完成之前就开始截图,导致文字显示为默认字体。解决办法是在page.goto之后加await page.evaluate(() => document.fonts.ready),确保字体加载完毕再开始渲染。
第二个坑是deviceScaleFactor和page.screenshot的clip参数冲突。如果你同时设置了deviceScaleFactor: 2和clip: { x: 0, y: 0, width: 1920, height: 1080 },截图出来的尺寸会是 3840x2160,但clip是按 CSS 像素计算的,所以实际截取的区域只有左上角四分之一。解决办法是不要同时用这两个参数,或者把clip的宽高乘以deviceScaleFactor。
第三个坑是 ffmpeg 的-crf参数。很多人以为-crf 0就是无损,但实际上-crf 0会生成巨大的文件,而且编码速度极慢。对于大多数场景,-crf 18已经足够,文件大小只有-crf 0的十分之一,画质差异肉眼几乎看不出来。
最后分享一个小技巧:如果你需要视频循环播放,可以在 HTML 里把动画设计成无缝循环,然后用 ffmpeg 的-stream_loop -1参数生成无限循环的视频。但注意,-stream_loop -1会生成一个无限长的文件,你需要配合-t参数指定时长,比如-t 30生成 30 秒的循环视频。
还有一个技巧是给视频加字幕。你可以在 HTML 里用<div>模拟字幕样式,然后逐帧渲染。但更高效的做法是用 ffmpeg 的subtitles滤镜,直接烧录 SRT 字幕文件:
ffmpeg -i output.mp4 -vf "subtitles=subtitle.srt:force_style='FontSize=24,PrimaryColour=&HFFFFFF'" output_with_sub.mp4这样字幕的样式和位置可以独立调整,不需要重新渲染整个视频。
我个人在实际操作中的体会是,hyperframes 这套流程最大的价值不是替代专业视频软件,而是填补了“批量、自动化、可编程”这个空白。当你需要生成 10 个以下视频时,手动做可能更快。但当你需要生成 100 个、1000 个,或者需要每天更新时,这套流水线的优势就体现出来了。它把视频制作从“手工艺”变成了“工业流程”,而 AI coding agents 的加入,让这个流程的入口变得更宽——你不需要精通 CSS 动画,只需要能描述清楚你想要什么。