ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用HTML写视频:hyperframes架构与Agent自动生成视频实战

用HTML写视频:hyperframes架构与Agent自动生成视频实战 前阵子研究 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日志、pageerror、requestfailed全部捕获Agent 拿到这些信息后很容易定位代码问题。比较麻烦的是视觉层面的检查因为纯文本的上下文无法直接“看到”截图所以通常需要借助多模态模型来做目标检测。让审查器判断“标题是否在画面中央”“三个卡片是否依次进入”“背景色是否是预期颜色”这类问题多模态模型的能力表现得相当稳定。如果不上多模态模型也可以退而求其次通过 DOM 查询来间接验证。比如检查某个元素的getBoundingClientRect()是否在视口内或者读取window.getComputedStyle(element).opacity是否达到预期值。一个典型的 Agent 自检循环长这样编码器生成一版 scene 页面。渲染控制层在 t0.5s、2s、6s 三个时间点截图。多模态审查器观察截图发现 2 秒时第二个特性卡片还没出现。审查器附带 DOM 查询结果animation-play-state是paused并且卡片的起始transform: translateX(100px)未生效。反馈给编码器卡片动画未触发请检查 class 绑定和动画起始状态。编码器修改代码重新进入循环直到指标全部通过。这个循环的收敛速度直接取决于你对 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.mp4list.txt的内容格式也很简单file opening.mp4 file features.mp4 file ending.mp4这条路径我已经在实际项目中验证过多次它的最大特点是把“写视频”变成了“写网页加脚本控制”整个调试体验非常顺滑。打开生成的 scene.html在浏览器开发者工具里拖动动画时间线所见即所得哪一帧不对直接改代码改完重新跑一遍脚本就出了新视频。5. 落地时最容易被坑的五个细节字体、单位、白屏、时序与成本这套流程在原理上并不复杂但真正落到生产环境时有些问题不踩一次很难注意到。我把最容易让项目翻车的几个细节整理成清单按照“优先解决程度”排个序。第一个坑是字体渲染。headless 浏览器默认字体有限很多中文系统字体在容器里可能不存在页面显示出来的效果是一堆占位符方块。这个问题最隐蔽因为本地开发环境有字体截图看着正常一换到纯净的 Linux 服务器就跑出来豆腐块。解决方法是把需要用到的字体以font-face形式内联进 HTML用 woff2 格式既能控制体积又不需要依赖系统字体环境。我这里还有一个保险做法在生成帧之前先执行fc-list :langzh检查当前环境有没有可用中文字体没有就直接报错而不是生成一堆废帧。第二个坑是 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、修改样式、重新渲染形成完整闭环这对于想批量生成视频、或者让非专业用户通过自然语言创作视频的场景特别适用。如果你也想尝试这条路可以先从一个十几秒的图文卡片动画入手把截帧、合成、自检这一圈跑通之后再逐渐往里面叠加更复杂的场景和转场。
返回列表