先说结论:我做了一个小工具,你给它一个 HTML 页面,它逐帧截图、编码成 MP4。
听起来平平无奇,但它解决了我做视频时最难受的三件事:改一个字要重开工程 、同样的操作两次做出来不一样 、AI 生成的画面里文字全是糊的。
开源地址:github.com/wx4766/html-to-video(MIT,55 个自动化用例)
一、起因:AI 会写 HTML,但不会写视频
这两年视频生成模型进步很大。但你只要让它画一句中文标题,结果必然是这样:笔画粘连、多一笔少一画、或者干脆给你造几个不存在的字。
原因不复杂------它是在像素空间里"画"字,而汉字的结构信息在扩散过程里被丢掉了。
但同一个模型,让它写一段 HTML + Canvas 动画,质量高得惊人。因为它训练数据里有海量的网页代码,而代码是结构,不是像素。
所以最划算的组合是:模型画背景,代码画文字。
问题来了:代码画出来的画面,怎么变成视频?
二、主流方案,以及我为什么不满意
三个绕不开的名字:Remotion、Motion Canvas,以及剪映/PR/AE 这类剪辑软件。
剪辑软件:改一句文案,得打开工程、找到图层、改文字、重新导出。一个片子改五遍文案,一晚上就没了。而且没有任何"版本"的概念------你没法 diff 两个视频。
Remotion:思路很好,用 React 组件描述视频。但它要求你把页面重写成 React 组件、上打包器。对已经有 HTML 的人来说是重做一遍。而且它对 4 人以上团队是商业许可。
Motion Canvas:编辑器体验很好,适合一帧帧精修分镜。但它也是"建工程"的路子。
我的需求特别窄:我手里已经有一个能跑的 HTML 页面了,我只想要一个 mp4。
于是就有了这个工具。它不提任何框架,只要你的页面遵守一个约定。
三、核心只有一个约定:画面是时间的纯函数
javascript
window.__seek = (t) => {
// 把 t 时刻的画面完整重绘一遍
};
window.__ready = true;
就这样。渲染器做的是:
javascript
for (let i = 0; i < 总帧数; i++) {
await page.evaluate(t => window.__seek(t), i / fps);
const png = await page.screenshot();
}
// 把 png 序列交给 ffmpeg
关键在"纯函数"三个字 。你的 frame(t) 不能依赖上一帧的状态,不能用 Math.random(),不能用 Date.now()。给它同样的 t,它必须画出同样的像素。
这条铁律换来三个好处:
- 可复现:今天渲的和下周渲的逐像素一致,改坏了能二进制 diff 出来。
- 可并行:4 个 worker 同时渲不同区间,不会互相干扰(因为它们没有共享状态)。
- 可断点续渲:渲到 80% 崩了,从断点接着来,拼出来的片子跟一次渲完的一模一样。
第三条是我加 --resume 时的验收标准:不只测"能续上",而是要求续渲产物的 MD5 和全量渲染完全相同。实测 960×540 / 80 帧,跑到 66% 强杀,53 帧全部复用,最终 MD5 一致。
四、真踩过的四个坑(这部分可能对你更有用)
坑 1:画布写死 1920×1080,改尺寸会静默裁掉右下角
早期模板里写的是 canvas.width = 1920。用户传 --width=1080 --height=1920 渲竖屏,出来的片子照样是 1920×1080,只是右下角被裁了------不报错、不警告,你只有在回抽帧的时候才发现白跑一晚上。
修法有两层:画布跟随视口(cv.width = window.innerWidth),渲染器再主动量一次画布尺寸,和视口不一致就告警。
第二层更重要。"静默出错"比"报错"贵得多,它吃的是你的时间,不是你的注意力。
顺带解决了一个隐蔽问题:竖屏不能用横屏的纵向缩放比做行距。横屏时 SY = H/1080 = 1,竖屏时 SY = 1.78------直接拿它算行距,整栏文字会散架。所以竖屏单独走一套 VSY = √(SX·SY),字号整体放一档。
坑 2:一个永远说"通过"的验收工具,等于没有验收
我写了 qa.js 做自动验收:测运动量、最长静止、帧间跳变、确定性、文字框景、字体回退。一开始它很听话,每次都绿。直到我意识到------我从没见过它报错。
于是做了个对照组:故意做一个坏页面(塞随机数、半截字、叠字、进字幕带、中间几秒全黑、控制台报错),测试要求它必须判失败并逐条报出线索。
后来又发现两个真实盲区:
离屏画布里的字查不到。 文字先画进离屏 canvas 再 drawImage 贴到主画布时,旧的钩子只拦 fillText,看到的是"一块贴图"。修法是给 drawImage 做坐标映射,把源图上的文字外框追到目标画布。
DOM 文字必然误报"叠字"。 按文本节点取所有行框的并集,多行段落的并集框会把行内 span 整个包住,段落和自己的强调词 100% 被判成叠字。改成按视觉行分块后,同一份对照页的线索从 6 条降到 1 条------剩下那条是真的出界。
这两个都是"修一个 bug 引入另一个"的典型。所以现在的规矩是:判据的纯逻辑必须能脱离浏览器单测,而且页面里跑的和单测跑的是同一个函数体(把函数 toString 注入页面),杜绝两份实现漂移。
坑 3:透明视频的 alpha,写进文件了但播不出来
我想做"文字层叠到 AI 生成的背景上",所以要透明输出。
标准答案是 VP9 + yuva420p:
bash
ffmpeg -pix_fmt yuva420p -auto-alt-ref 0 ...
文件体积从 7.8 KB 涨到 16.8 KB,说明 alpha 数据确实写进去了。但 Chromium 播出来------整帧 alpha 全是 0,连文字都透明了 。换 lossless、固定码率、-lag-in-frames 0,全都一样。
结论:这条路在我这套工具链上走不通,而且我没法自证它是对的。所以最终只提供 ProRes 4444(.mov),体积是 H.264 的 6.5 倍。小体积透明素材请导入剪辑软件后再导 webm,那是人家的活。
还有个小细节:透明模式下,全屏的半透明光斑也必须跳过。只跳底色、留着 5% 光斑时,四角 alpha 是 12 而不是 0,叠到实拍上整片泛白,剪辑师第一反应是"这素材脏"。
坑 4:改一行参数,就能让画面差一点点
我把截图参数从 screenshot({clip}) 改成 screenshot({clip, omitBackground: ALPHA}),非透明的老路径 PSNR 从 inf 掉到 83.75 dB。
原因:omitBackground 即使是显式的 false,也会让 Chromium 走另一条 PNG 编码路径,色彩标记从 icc 变成 unknown。
逐像素比对后确认,差异只有 294 / 518400 个像素、最大通道差 1,位置在左侧装饰线的抗锯齿边缘------是跨进程光栅化的噪声。但这说明一件事:"改了参数应该没影响"这种判断,必须用像素比对来验证,不能靠感觉。
五、现在它能做什么
- 10 个模板 :片头、条形图、折线、大数字、时间线、前后对比、流程图、解说卡、竖屏卡、竖屏字幕卡。改顶部一个
CFG配置块就能出片。 - 横竖屏自适应:16:9 / 9:16 / 720p,换两个参数,版式自己重排,不裁切。
- 透明输出 :
--alpha出 ProRes 4444,叠到实拍或 AI 背景上。 - 外部字幕时间轴 :
--srt支持 SRT / VTT / JSON,配音改了字幕跟着走。实测每句对轴误差 0 ms(有--cue-log导出 CSV,误差是可算的数,不用靠眼睛)。 - 三套配色 :
CFG.theme一行切换暗色 / 浅色 / 霓虹。 - 断点续渲 :
--resume,续出来的片子和一次渲完的 MD5 相同。 - 六维自动验收:确定性、运动量、最长静止、跳变、文字框景、字体回退。
六、诚实说局限
- 不能做写实镜头。没有镜头语言、没有景深、没有氛围。要那种质感请用视频生成模型。
- 确定性只在单机成立。默认用系统字体(微软雅黑 / 苹方),换台机器字形就变了。要跨机器复现得内嵌字体子集。
- 渲染吃 CPU 。1080p 大约 0.5 秒一帧,4 worker 下 10 秒 30fps 的片子约 2.5 分钟。调试时用
--stills只抽几帧。 - ffmpeg 必须是完整版 。Playwright 自带那个是
--disable-everything编译的,没有 H.264、没有 GIF、连 PNG 解码都没开。 - 验收是烟雾报警器,不是证明。它能抓黑屏、半截字、不确定性;抓不到"这个片子很无聊"。
七、最后
如果你也遇到过"改一句文案要重开工程",可以试试:
bash
git clone https://github.com/wx4766/html-to-video.git
cd html-to-video && npm install && npx playwright install chromium
cp assets/templates/title-card.html my-video.html # 改顶部 CFG
node scripts/render-video-seek.js my-video.html --duration=6 --fps=30
MIT,随便用。有问题直接提 issue,我基本当天回。