HeyGen 开源了一个叫 HyperFrames 的框架,让你用 HTML、CSS 和 GSAP 来做视频。不是概念演示,是真能用的那种。
为什么要用代码做视频
用过 After Effects 或 Premiere 的人都知道,手动调关键帧是个力气活。做一个 10 秒的片头可能要调半小时,改个颜色又得重来一遍。项目文件是二进制格式,Git 根本管不了,团队协作基本靠 U 盘传文件。
HyperFrames 的思路很简单:既然前端开发都是写代码,视频为什么不能也写代码?HTML 定义元素,CSS 控制样式,GSAP 做动画,所有东西都是文本文件。Git 能管,改起来方便,批量生成写个脚本就行。配合 AI 的话,直接说"把标题改成从左边滑入",改完立刻能看效果。
这套东西适合谁用?如果你是做电影级特效,老老实实用 AE。但如果你是前端开发者,经常要做数据可视化、产品介绍视频、动态字幕这类东西,HyperFrames 能省不少事。
实现原理
HyperFrames 是一个四层架构,从上到下:
less
CLI (hyperframes render)
↓
Producer (@hyperframes/producer) 负责完整渲染流水线
↓
Engine (@hyperframes/engine) 负责帧捕获
↓
Core (@hyperframes/core) 提供运行时、类型、FrameAdapter
用户写 HTML,CLI 调 Producer,Producer 驱动 Engine 逐帧捕获,Core 负责页面内的时间轴控制。
核心机制:Seek-and-Capture 循环
HyperFrames 的做法: 不播放,只 seek。每一帧都是独立的静态快照:
ini
for (let frame = 0; frame <= totalFrames; frame++) {
const time = Math.floor(frame) / fps; // 整数除法,无浮点误差
await adapter.seekFrame(frame); // 把动画拨到这一时刻
// 捕获当前像素
}
时间计算用整数帧号除以 fps,不依赖任何系统时钟。
帧捕获:HeadlessExperimental.beginFrame
引擎启动的是 chrome-headless-shell(专为 CDP 控制优化的最小 Chrome 二进制),通过 Chrome DevTools Protocol 调用 HeadlessExperimental.beginFrame。
这个 API 的作用是:显式命令合成器渲染一帧,并把像素 buffer 直接返回给调用方。效果是:
- 没有"等渲染完成"的时序问题
- 像素直接从 GPU 合成器取,不经过截图的 IPC 拷贝流程
- 每帧是原子操作,不存在半渲染状态
FrameAdapter 协议:动画运行时的接入层
HyperFrames 不锁定任何动画库。它定义了一个 FrameAdapter 接口,任何能"按帧 seek"的东西都能接入:
typescript
type FrameAdapter = {
id: string;
init?: (ctx: FrameAdapterContext) => Promise<void> | void;
getDurationFrames: () => number; // 视频总帧数
seekFrame: (frame: number) => void; // 把动画拨到第 N 帧
destroy?: () => void;
};
GSAP 的 adapter 实现大概是:
javascript
seekFrame(frame) {
const time = frame / fps;
gsap.globalTimeline.pause();
gsap.globalTimeline.seek(time); // 直接拨时间轴
}
seekFrame 必须是幂等的 (同一帧调两次结果相同),且必须支持随机 seek(可以先 seek 第 90 帧再 seek 第 10 帧),不能有顺序依赖。
window.__hf 协议:引擎和页面的通信桥
引擎(Node.js 进程)和页面(浏览器内)之间通过 window.__hf 对象通信:
typescript
interface HfProtocol {
duration: number; // 视频总时长(秒)
seek(time: number): void; // 引擎调这个来驱动帧 seek
media?: HfMediaElement[]; // 音视频元素声明(给引擎做音频抽取用)
}
页面加载完成后,Core 注入的运行时把自己挂在 window.__hf 上。引擎每帧调 page.evaluate(() => window.__hf.seek(t)),页面内的 FrameAdapter 响应,GSAP 时间轴被拨到对应位置,然后引擎立刻调 beginFrame 捕获。
任何实现了这个协议的页面都能被引擎渲染,不局限于 HyperFrames 格式的 HTML。
音频处理:单独抽取,最后混合
浏览器渲染是纯视觉的,音频不能从帧里捕获。Producer 的做法是把音频流程完全分离:
- 解析 HTML 里的
<audio>和<video>元素,读取data-start、data-duration、data-volume等属性 - 用 FFmpeg 从源文件里单独提取音轨,按时间轴剪切、调音量
- 所有音轨混合成一个主音轨
- 视频帧编码完成后,再用 FFmpeg 把视频和音轨 mux 到一起
并行渲染
单个 Engine session 是串行的(一帧一帧 seek),但 Producer 会开多个 session 并行:
scss
calculateOptimalWorkers(totalFrames) // 根据 CPU 核数算出最优 worker 数
distributeFrames(totalFrames, workers) // 把帧分段,每个 worker 负责一段
executeParallelCapture(tasks) // 并行跑,各 worker 独立开 Chrome 实例
每个 worker 是完全独立的 capture session,有自己的 Chrome 进程和页面实例,不共享状态。最后按帧序号合并,送给 FFmpeg 编码。
确定性保证
同一份 HTML,任意时间在任意机器上渲染,输出的 MP4 应该二进制相同(Docker 模式下严格成立)。这靠几件事保证:
- 时间用
Math.floor(frame) / fps计算,不用Date.now() seekFrame幂等且无顺序依赖- 所有资源在渲染前必须加载完(有
__renderReadyreadiness gate) - 禁止
Math.random()(无 seed) - Chrome 版本固定(Docker 模式下完全锁定)
本地渲染可能因系统字体和 Chrome 小版本差异有微小像素差异,Docker 模式消除这个问题。
完整流程图
css
npx hyperframes render
│
▼
CLI → Producer
│
├─► 解析 HTML,提取音视频元素
│
├─► 启动 File Server(HTTP 本地服务,给 Chrome 加载文件用)
│
├─► 启动 N 个 worker(每个 worker 一个 Chrome 实例)
│ │
│ ▼
│ initializeSession(html)
│ │
│ ├─► 注入 Core 运行时(挂 window.__hf)
│ │
│ └─► for each frame:
│ window.__hf.seek(t) ← GSAP timeline.seek(t)
│ HeadlessExperimental.beginFrame
│ → pixel buffer
│
├─► pixel buffer → FFmpeg → video.mp4(无音频)
│
├─► 音频抽取 → 混合 → audio.wav
│
└─► FFmpeg mux(video.mp4 + audio.wav) → output.mp4
安装:
bash
npx hyperframes init my-video
项目结构:
perl
my-video/
├── index.html # 主时间轴文件
├── meta.json # 项目元数据(id, name)
├── hyperframes.json # 路径配置
├── narration.wav # 音频文件(可选)
├── transcript.json # 转录文件(可选)
├── compositions/ # 子组件目录
│ └── intro.html
└── assets/ # 静态资源
├── images/
└── fonts/
核心概念
1. 时间轴声明
用 data 属性定义时间:
html
<div
class="clip"
data-start="0"
data-duration="5"
data-track-index="1"
>
<h1>Hello World</h1>
</div>
必须的三个属性:
data-start: 开始时间(秒)data-duration: 持续时长(秒)data-track-index: 图层索引(类似 AE)
注意 :有时间属性的元素必须加 class="clip",框架用它控制显示。
2. GSAP 动画
javascript
// 创建并注册时间轴
var tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main"] = tl;
// 添加动画
tl.from(".title", {
y: 100, // 从下方 100px 进入
opacity: 0, // 从透明到不透明
duration: 1.0,
ease: "power3.out"
}, 0.2); // 在 0.2 秒处开始
常用缓动函数:
power2.out- 快入慢出power3.out- 更强烈的快入慢出back.out(1.7)- 回弹效果elastic.out- 弹性效果
3. 字幕同步
javascript
var GROUPS = [
{ id: "cg-0", start: 0.5, end: 2.0 },
{ id: "cg-1", start: 2.2, end: 3.8 }
];
GROUPS.forEach(function(group) {
var el = document.getElementById(group.id);
// 入场
tl.fromTo(el,
{ opacity: 0, visibility: "visible" },
{ opacity: 1, duration: 0.3 },
group.start
);
// 退场
tl.to(el, { opacity: 0 }, group.end - 0.15);
tl.set(el, { visibility: "hidden" }, group.end);
});
效果展示
我做了个智能手表的产品介绍视频,14 秒,三个场景。
三个场景的安排:
- 场景 1(0-4s):产品名 + 价格,用了 back.out 回弹效果
- 场景 2(4-10s):三张功能卡片,stagger 交错出现
- 场景 3(10-14s):CTA 按钮,elastic.out 弹性动画
下面拆开看看每个场景怎么写的。
场景 1:产品展示
javascript
// 产品名称从下方弹入
tl.from(" .product-name", {
y: 100, opacity: 0, duration: 0.8, ease: "power3.out"
}, 0.3);
// 价格放大淡入(带回弹)
tl.from(" .price", {
scale: 0, opacity: 0, duration: 0.6, ease: "back.out(1.7)"
}, 1.2);
场景 2:功能卡片
javascript
// 三张卡片交错出现
tl.from(" .feature-card", {
y: 60,
opacity: 0,
duration: 0.5,
stagger: 0.2 // 关键:每张间隔0.2秒
}, 4.8);
CSS 毛玻璃效果:
css
.feature-card {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.2);
}
场景 3:CTA按钮
javascript
// 按钮弹入
tl.from(" .cta-button", {
scale: 0, opacity: 0, duration: 0.6, ease: "elastic.out(1, 0.5)"
}, 11.0);
// 脉冲动画(吸引点击)
tl.to(" .cta-button", {
scale: 1.1, duration: 0.3, repeat: 3, yoyo: true
}, 11.8);
总结
HyperFrames 的核心思路就是把视频当代码管。对前端开发者来说,这套东西上手很快,HTML、CSS、GSAP 都是熟悉的技术栈。
不过也别指望它能做电影级特效。毕竟是基于浏览器渲染的,复杂的 3D 动画、粒子效果这些做不了。但对于产品介绍、数据可视化、字幕动画这类需求,够用了。
