用HTML写视频:hyperframes架构与Agent自动生成视频实战
2026/9/15 4:25:34 网站建设 项目流程

前阵子研究 AI 生成视频,刷到 hyperframes 这个概念之后,一时没忍住就跳了进去。用 HTML 写视频,再让 Agent 自动把自然语言转成成片,这个思路听起来像是把浏览器当摄影棚,把 CSS 动画当摄像机运动,把 JavaScript 当剪辑师。折腾了一段时间后,我确认这条路线是能走通的,而且相比传统的逐帧绘图方案更接近前端开发者的直觉。这篇文章就把整套思路、架构、一份可复现的最小管线,以及实际落地时踩过的坑都摊开讲清楚。

1. 视频不该一帧帧硬画,HTML 就是天然的帧描述语言

早期用代码生成视频,最常见的路子是调用绘图库一帧一帧地往画布上画。OpenCV 画几何图形、Manim 做数学动画,或者直接用 PIL 拼图再交给 ffmpeg 合成。这类方案的问题在于,每一帧的状态都需要程序员手动计算,哪怕是简单的位移和透明度变化,也要写一堆坐标插值。曾几何时,我为了做一个 3 秒的圆球弹跳动画,硬是把 90 帧的状态全部算了一遍,回过头来看非常愚蠢。

后来出现了 React 视频库这类方案,用组件化思维描述时间轴,算是一个进步。但它本质上还是把视频当成一个树形结构来生成,每一帧的样式要通过 JavaScript 运行时计算。遇到复杂排版、渐变、滤镜、WebGL 特效时,想用代码精确描述仍然很费劲。因为前端领域早就有一个表达视觉状态的能力很强的语言,它就是 HTML 加 CSS。

hyperframes 的核心出发点,就是意识到 HTML 本身就是一种天然适合描述视频帧的语言。一个网页里,DOM 结构决定了画面里有什么,CSS 决定了它们长什么样、如何运动,JavaScript 则可以控制任意时间点上的状态。浏览器本身就是一个高性能的渲染引擎,它能完成排版、光栅化、合成,甚至 GPU 加速,这些能力完全可以直接借用来渲染视频。

如果你把时间想象成一种特殊的用户交互——不是鼠标滚动,而是播放进度——那视频的每一帧,本质上就是 DOM 在某个时间点上的快照。CSS 动画和 Web Animations API 已经能精确控制元素在特定时间的外观,剩下的工作只是告诉浏览器“请把这一刻的画面输出成图片或视频流”。这就是 hyperframes 这个名字的由来:超级帧,每一帧都是一个结构化的 HTML 文档,而不是一堆不可读的像素矩阵。

这种思路带来的好处非常明显。首先,画面中的文字是真实文本,不会出现像素模糊,也不需要在后期单独烧字幕。其次,布局和动效可以复用整个 Web 生态,几乎任何你能在网页上看到的视觉效果,都能直接变成视频效果。最后,也是最重要的一点,Agent 可以像调试网页一样去检查视频源文件,DOM 是可查询的,样式是可计算的,渲染错误是可以定位的。这是传统视频中间表示完全不具备的特性。

为了更直观地理解不同方案的差异,我把它们放在一起做了个对比:

生成视频方案中间表示改稿成本动效表现力Agent 可检查性
传统绘图库逐帧图像高,改一个元素要重算整段逻辑依赖代码量低,只能看像素
Manim / Processing代码场景树中,局部修改但心智负担高几何图形强,复杂 UI 弱
React 视频框架React 组件树中,状态逻辑不好调能画 UI,但动画不够自然中,可读组件但不可查询最终帧
hyperframes 思路HTML 文档 + CSS/JS低,改 CSS 就等于改视频强,继承网页全部能力高,DOM 和 CSSOM 都可检查

在实际探索中,我一开始也怀疑这种方案会不会太重。毕竟浏览器跑视频渲染,听起来没有直接调 ffmpeg 来得干净。但真正动手之后发现,渲染性能完全不是瓶颈。1080p 的静态页面截图大约只需要几十毫秒,即便是包含大量 CSS 动画的页面,在无头浏览器里通过控制动画时间逐帧输出,也能稳定做到每帧百毫秒级别。而省下来的开发时间,是用什么方案都比不了的。

2. hyperframes 架构拆解:从自然语言到 HTML 再到成片的链路

要把整个流程跑通,核心链路可以拆成四层:意图理解层、代码生成层、渲染控制层、合成输出层。这个链路其实和日常前后端联调很像,每一层都有明确的输入输出,层与层之间用中间产物衔接。

意图理解层负责把自然语言描述转换成可执行的分镜脚本。比如用户说“做一个 10 秒的产品宣传视频,开头标题淡入,中间是三个特性卡片依次滑入,结尾出现联系方式”,Agent 需要先把它拆成时间片:0 到 2 秒标题淡入,2 到 6 秒特性卡片依次滑入,6 到 10 秒联系方式展示。每个时间片都附带对应的画面文案、动效类型、持续时间。这些信息最终会被结构化成一个 JSON 脚本,作为下一层代码生成的输入。

代码生成层严格来说不是一个单纯的 HTML 生成器,而是一个具备反馈能力的 Agent 单元。它会基于分镜脚本,输出一个包含了 CSS 动画和 JavaScript 控制的 HTML 文件。这里并不要求 Agent 一次性生成完美代码,只要它能输出一个可运行的页面,后续的审查器会帮助发现问题。为了降低生成难度,我通常会约定一套页面骨架模板,让 Agent 在模板上填充内容。比如全局用统一的.scene容器,动画全部通过添加和移除 class 来触发,避免生成难以控制的setInterval逻辑。

渲染控制层是整个框架里最关键的环节。它运行在 Puppeteer 或 Playwright 这样的浏览器自动化工具里,负责加载 HTML,并按时间轴采集画面。这里需要理解两种不同的采集策略:帧快照和连续录制。

帧快照的策略是,在给定时间点暂停页面动画,把当前页面截图保存为一个 PNG 文件。十秒钟、30 帧每秒的视频,最终会得到 300 张 PNG,再用 ffmpeg 合成。连续录制的策略则是启动 CDP 的页面流录制,直接捕获浏览器渲染过程中的实时视频流,最后封装成视频文件。帧快照的精确度高,适合需要逐帧检查和动态调整的场景;连续录制效率高,适合画面内容简单、动画流畅度要求高的场景。

合成输出层相对简单,就是把刚才得到的帧序列或视频流,和音频轨道、字幕轨道合并,输出成最终的 MP4 文件。ffmpeg 在这个环节是标准工具,但参数设置有不少细节,稍后会单独讲。除了一条主链路,整个架构还必须有一个贯穿始终的反馈闭环。Agent 生成 HTML 之后,渲染控制层会在几个关键时间点截图,交给代码审查模块判断是否符合预期。如果发现元素重叠、文字溢出、动画未执行,就把错误信息和当前页面状态一起反馈给 Agent,要求它修改代码。这个反馈循环是 hyperframes 能够稳定产出视频的根本原因,否则 Agent 生成的 HTML 可能只有一半能真正渲染出理想效果。

下面是一个最小化的目录结构,我实际用下来的组织方式:

video-project/ ├── scenes/ # 每个分镜一个场景 │ ├── opening/ │ │ ├── index.html │ │ ├── style.css │ │ └── main.js │ ├── features/ │ │ ├── index.html │ │ ├── style.css │ │ └── main.js │ └── ending/ │ ├── index.html │ ├── style.css │ └── main.js ├── frames/ # 临时帧序列 │ ├── scene_01 │ └── scene_02 ├── generate.js # 主控制脚本 └── output.mp4

每个场景独立成目录,好处有两个:Agent 可以分场景并行生成,互相不依赖;单个文件体积小,上下文窗口不容易爆掉。而且定位问题时,只需要打开对应的 index.html 就能在普通浏览器里预览,调试体验和改普通网页没有任何区别。

3. Agent 在渲染闭环里真正要解决的事:自检、修错、保证视觉一致性

很多人在聊“用 Agent 生成视频”时,把重点都放在“让 Agent 写动画代码”上,仿佛 LLM 只要会写 CSS 动画就够了。但实际跑了几个案例之后,你会发现 Agent 写代码的能力只是底座,真正的难点在于它如何知道自己写错了,以及如何把一次不完美的输出修正到接近成品。

我采用的 Agent 结构不是单个大模型走到底,而是拆成规划器、编码器、审查器三个角色。规划器负责理解分镜脚本,把它转成具体的时间轴和页面区块规划;编码器负责根据规划生成 HTML、CSS、JavaScript;审查器则像是 QA,围绕渲染结果的截图、DOM 状态和运行时日志进行判断。这里有一点很反直觉:规划器和编码器可以共用同一个大模型,但提示词必须分开,尤其是审查器的提示词,需要专门强调“以截图和 DOM 状态为准,不要猜测”。

审查器最基础的任务是检查运行时错误。Puppeteer 可以把console日志、pageerrorrequestfailed全部捕获,Agent 拿到这些信息后很容易定位代码问题。比较麻烦的是视觉层面的检查,因为纯文本的上下文无法直接“看到”截图,所以通常需要借助多模态模型来做目标检测。让审查器判断“标题是否在画面中央”“三个卡片是否依次进入”“背景色是否是预期颜色”这类问题,多模态模型的能力表现得相当稳定。如果不上多模态模型,也可以退而求其次,通过 DOM 查询来间接验证。比如检查某个元素的getBoundingClientRect()是否在视口内,或者读取window.getComputedStyle(element).opacity是否达到预期值。

一个典型的 Agent 自检循环长这样:

  1. 编码器生成一版 scene 页面。
  2. 渲染控制层在 t=0.5s、2s、6s 三个时间点截图。
  3. 多模态审查器观察截图,发现 2 秒时第二个特性卡片还没出现。
  4. 审查器附带 DOM 查询结果:animation-play-statepaused,并且卡片的起始transform: translateX(100px)未生效。
  5. 反馈给编码器:卡片动画未触发,请检查 class 绑定和动画起始状态。
  6. 编码器修改代码,重新进入循环,直到指标全部通过。

这个循环的收敛速度直接取决于你对 Agent 的约束程度。如果完全放手让它自由发挥,一个 10 秒短视频可能跑上十几轮都不稳定。我之后学到的经验是,在规划器阶段就把动画方案固定下来。比如规定所有入场动画都用 Web Animations API 编写,所有时间线都通过document.timeline控制,禁止setTimeout。这样审查器的检查点就非常明确,Agent 也不会写出各种奇形怪状的实现。

视觉一致性是另一个容易被忽视的坑。Agent 在迭代过程中很容易为了修复一个 bug,顺带改掉了某个元素的字体、颜色或者间距,导致最终成片和最初的设计稿完全不是一回事。要解决这个问题,需要在每次反馈循环时,把上一个版本的关键帧截图和当前版本的关键帧截图同时交给审查器,明确要求“只能修改被点名的属性,其他视觉元素必须保持一致”。我甚至在提示词里列了一个白名单,比如“只允许修改动画相关属性,字体和颜色保持 v1 不变”,效果立竿见影。

还有一点涉及成本。每次调用多模态模型审查截图都会有 token 开销,尤其是图片反复上传,消耗很快。为了控制成本,可以把审查粒度分级:首个场景必须完整审查所有关键帧,后续相似场景只抽查首尾两帧。把整个项目看成一个不断收敛的过程,Agent 越到后期要改的东西越少,反馈循环也会越来越短。

4. 从零手搓一个最小可用管线:环境、代码与参数

这里给出一份可以直接跑通的最小实现,整个流程基于 Node.js、Puppeteer 和 ffmpeg。环境方面先确保安装了 Node.js 18 以上版本,以及 ffmpeg 命令。另外还需要一个可用的 LLM API,用来生成 HTML 内容。

先说设计思路。视频总时长 8 秒,分辨率 1920x1080,帧率 30fps。为了让帧输出尽量可控,不在 HTML 里写自动播放的 CSS 动画,而是把动画元素全部保留在初始状态,通过 Web Animations API 在特定时间点设置currentTime来控制画面进度。这样截图时不需要等待真实时间流逝,只要直接跳转动画时间轴,就能得到精确的一帧。这个方案的好处是,即使机器性能波动,也不会出现帧间对齐错乱。

核心脚本的逻辑比较简单。它调用 LLM 生成一个单文件 HTML,这个 HTML 里包含了一段从 0 到 8 秒的时间轴动画。然后 Puppeteer 打开这个页面,循环 240 次(8 秒 × 30fps),每次设置动画时间为当前帧对应的时间,截图保存到frames/目录。最后调用 ffmpeg 把图片序列合成视频。

下面是一个简化但可运行的关键实现:

import puppeteer from 'puppeteer'; import { execSync } from 'child_process'; import fs from 'fs'; const WIDTH = 1920; const HEIGHT = 1080; const FPS = 30; const DURATION = 8; const TOTAL_FRAMES = FPS * DURATION; async function fetchHtmlFromAgent(prompt) { // 调用 LLM,返回一个完整的 HTML 字符串 // 这里以伪代码代替,实际需要根据所用模型 API 调整 const response = await callLlm(`为下面的视频写一个 HTML 页面,包含 0-8s 的时间轴动画,元素使用 Web Animations API,不自动播放,使用 timeline.currentTime 控制。\n${prompt}`); return response.text; } async function generateFrames(htmlPath) { const browser = await puppeteer.launch({ headless: 'new', args: [`--window-size=${WIDTH},${HEIGHT}`], }); const page = await browser.newPage(); await page.setViewport({ width: WIDTH, height: HEIGHT, deviceScaleFactor: 1 }); await page.goto(`file://${htmlPath}`, { waitUntil: 'networkidle0' }); fs.mkdirSync('frames', { recursive: true }); for (let i = 0; i < TOTAL_FRAMES; i++) { const time = (i / FPS) * 1000; // 转为毫秒 await page.evaluate((t) => { document.timeline.currentTime = t; }, time); await page.screenshot({ path: `frames/frame_${String(i).padStart(4, '0')}.png` }); } await browser.close(); } async function main() { const prompt = '产品发布会开场:标题“hyperframes”淡入,背景从深蓝渐变到紫色,2秒后标题轻微放大,4秒后出现副标题“HTML as Video”,保持到结束。'; const html = await fetchHtmlFromAgent(prompt); fs.writeFileSync('scene.html', html); await generateFrames('scene.html'); execSync(`ffmpeg -y -framerate ${FPS} -i frames/frame_%04d.png -c:v libx264 -pix_fmt yuv420p -crf 18 output.mp4`); } main();

需要特别说明的是,document.timeline.currentTime = time只有在页面开启document.timeline支持时才有效。更稳妥的做法是显式控制所有动画:

await page.evaluate((t) => { document.getAnimations().forEach((anim) => { anim.currentTime = t; }); }, time);

这段代码会遍历页面里所有的动画实例,把它们统一设置到目标时间点。使用这个方法,前提是页面里的动画在document.getAnimations()的返回列表里,也就是通过 CSS 动画或者 Web Animations API 创建的动画都能被控制。

ffmpeg 合成时,-framerate 30告诉它输入图片的帧率,-crf 18是高质量 H.264 的常用参数,数值越小画质越好,文件也越大。如果视频需要音频,可以再加一条音频轨道进行合并:

ffmpeg -y -framerate 30 -i frames/frame_%04d.png -i audio.mp3 -c:v libx264 -c:a aac -shortest output.mp4

如果是制作更复杂的多场景视频,建议每个场景单独生成 HTML 和帧序列,然后先用上面的方式合成场景级 MP4,最后再用 ffmpeg 的 concat demuxer 按顺序拼接。直接在整个时间线上截取跨场景的视频帧会非常吃内存,也容易因为某个场景出错导致全盘重来。

ffmpeg -f concat -safe 0 -i list.txt -c copy final.mp4

list.txt的内容格式也很简单:

file 'opening.mp4' file 'features.mp4' file 'ending.mp4'

这条路径我已经在实际项目中验证过多次,它的最大特点是把“写视频”变成了“写网页加脚本控制”,整个调试体验非常顺滑。打开生成的 scene.html,在浏览器开发者工具里拖动动画时间线,所见即所得,哪一帧不对直接改代码,改完重新跑一遍脚本就出了新视频。

5. 落地时最容易被坑的五个细节:字体、单位、白屏、时序与成本

这套流程在原理上并不复杂,但真正落到生产环境时,有些问题不踩一次很难注意到。我把最容易让项目翻车的几个细节整理成清单,按照“优先解决程度”排个序。

第一个坑是字体渲染。headless 浏览器默认字体有限,很多中文系统字体在容器里可能不存在,页面显示出来的效果是一堆占位符方块。这个问题最隐蔽,因为本地开发环境有字体,截图看着正常,一换到纯净的 Linux 服务器就跑出来豆腐块。解决方法是把需要用到的字体以@font-face形式内联进 HTML,用 woff2 格式,既能控制体积又不需要依赖系统字体环境。我这里还有一个保险做法,在生成帧之前先执行fc-list :lang=zh检查当前环境有没有可用中文字体,没有就直接报错而不是生成一堆废帧。

第二个坑是 CSS 单位不统一导致画面在不同分辨率下完全变形。视频是固定分辨率的,1920x1080 视口里写100vh可能没问题,但一旦切换到其他环境或加上了浏览器边框,就会出现滚动条。最稳妥的做法是让 Agent 在生成代码时统一使用px或相对于视频容器尺寸的单位,并且在index.html里禁用滚动和缩放。我通常会在生成模板的<head>中强制加入下面的样式:

html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }

这样无论 Puppeteer 的视口如何初始化,页面本体始终是 1920 乘 1080,不会因为边距和滚动条产生黑边或者截断。

第三个坑是白屏和资源加载的时序问题。Agent 生成的 HTML 很喜欢外链字体、图片和第三方样式库。本地调试时网络快,页面一两秒就渲染完整,但换成自动化截图时,如果图片还没加载完就开始截,就会得到一堆破图或者空白背景。Puppeteer 的waitUntil: 'networkidle0'能解决一部分问题,但对于持续加载的轮询脚本可能会卡死。我后来的习惯是,在向 Agent 下发任务时明确要求“所有资源内联,不允许外链”。这个约束可以直接写进提示词里,让 Agent 把图片转成 base64、样式内联进<style>、脚本内联进<script>。损失的是代码可读性,换来的是渲染稳定性和可移植性,这笔账很划算。

第四个坑是时序漂移。如果你用setInterval驱动动画,再配合setTimeout去控制截图时间,最终产出的视频几乎一定会出现卡顿或重复帧。原因很简单,浏览器主线程的定时器精度受负载影响,尤其在自动化环境里。不要依赖真实时间流逝,而是像上一节说的那样,用 Web Animations API 把所有动画的时间轴集中到一个“主时钟”上,截帧时直接把主时钟拨到目标时间。这样即便这帧画了 200 毫秒、那帧画了 50 毫秒,最终帧的内容都对应正确的时间点,合成后的视频动画是流畅的。

第五个坑是成本,不仅仅是钱,还有迭代等待时长。Agent 每自检一次,都要经过“生成代码—启动浏览器—截图—多模态审查—反馈”的完整回路,一个场景跑上十几轮可能耗掉几十分钟。为了控制成本,我把反馈周期做了分级。早期的规划阶段在静态 HTML 阶段就做一次人工预览,确认版式之后再进入动画阶段;动画阶段的审查只检查关键节点,比如入场完成、退场开始、文字完全展示这三个时刻;只有出现明确 bug 时才做逐帧检查。这个策略让单场景的平均迭代轮数从 12 轮降到了 4 轮左右。

此外,还有一个从工程上能明显优化速度的手段:并发渲染。多个场景之间没有依赖,可以用 Puppeteer 启动多个浏览器实例并行处理。我自己用 8 个并发实例处理过一段包含 6 个场景的宣传片,整个渲染时间从将近 20 分钟压到了 4 分钟。不过并发时需要留意内存,每个 Chrome 实例大概吃 300MB 内存,8 个实例就意味着 2.4GB 以上,小机器上要适当减并发数。

这套基于 hyperframes 思路搭建的 HTML 写视频管线,目前已经成为我处理短视频的主要工具。它的最大价值不在于省了多少手工剪辑时间,而是把视频从“不可程序化检查”变成了“可测试的页面产物”。Agent 能直接读取 DOM、修改样式、重新渲染,形成完整闭环,这对于想批量生成视频、或者让非专业用户通过自然语言创作视频的场景特别适用。如果你也想尝试这条路,可以先从一个十几秒的图文卡片动画入手,把截帧、合成、自检这一圈跑通之后,再逐渐往里面叠加更复杂的场景和转场。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询