# HTML to Video was not easy - here’s how we solved it.
**作者**: James Russo
**日期**: 2026-04-16T17:17:10.000Z
**来源**: [https://x.com/Rames_Jusso/status/2045230663805317349](https://x.com/Rames_Jusso/status/2045230663805317349)
---

The Problem
Creating video is hard, but LLMs are great at writing code specifically HTML. What if we can have LLMs write code to create a video?
The pitch was clean (and exciting!).
The reality was not…
About a year ago we started our journey. Huge prompts, lots of back-and-forth, and plenty of hand-written glue to cover what the model missed. We added an agent to hand hold the model. It helped, but it still wasn’t production ready.
Next up we tried Remotion. It’s the right shape for deterministic video (React components, great tooling, production ready rendering), but the React framework kept boxing the agent in.
Outputs got safer and more repetitive the more guardrails we added. When we dropped back to plain HTML/CSS/JS, the creativity came back.
That raised the real question:
“Can we keep the freedom of HTML and still render a deterministic MP4?”
HyperFrames is our answer:
- a minimal authoring model (HTML + data-* clip attributes)
- a pluggable animation runtime
- a render pipeline that forces headless Chrome to produce the same pixels every run
Browsers don’t want to do this. Rendering is threaded and asynchronous: images decode in the background, videos drop frames under load, animations follow the display clock. All of that is performance—and nondeterminism.
Long story short, we set out to solve it and we did!
However, we also wanted to ensure it worked well and grew with the models. One tell that a framework is fighting an agent is if you need the biggest model just to get working output.
We started by shaping the simplest version of HyperFrames around what Gemini Flash could reliably author.
From there, we ran evals across different models, tightened the skills and the runtime wherever they failed, and repeated.
The goal was not to optimize for one model.
It was to make the authoring model simple enough, and the agent scaffolding strong enough, that a wide range of models could produce usable compositions.
We build AI models and agents for a living. The feedback loop that tightens an agent's output is the same loop that tightens HyperFrames.
How We Got Here...
## The one trick: seek, don't play
Every composition in HyperFrames exposes exactly one thing to the runtime:
The renderer never calls play(). It calls seek(0), screenshots, seek(1/30), screenshots, seek(2/30), screenshots, until it has 300 frames for a 10-second 30fps video.
Time doesn't advance on its own. Nothing is driven by requestAnimationFrame. The browser's job is to hold a fixed frame until the next one is requested.
This one abstraction is what collapses two very different systems into one codebase.
The studio preview runs the same window.__hf inside an iframe with a postMessage bridge for play/pause/scrub.
The headless render runs the same window.__hf via Puppeteer and CDP. When a user scrubs the timeline, the preview iframe calls seek. When the renderer captures frame 147, it calls seek(147 / fps). Same code path. Same output.
Animation libraries plug in through a three-method FrameAdapter:
GSAP is the default because its timelines are already paused-and-seekable by design. timeline.pause() followed by timeline.totalTime(t, false) is the exact functionality we need. Lottie, CSS via WAAPI, and Three.js clocks all fit the same shape as well easily.
What doesn't fit is anything that insists on owning the clock: CSS keyframe animations without a controller, video elements, most canvas libraries running their own requestAnimationFrame.
For those you either wrap them in an adapter that takes the clock away, or you render them to frames offline and replay them as images. This is similar to how we handle videos which will explain more later.
## Capture: controlling Chrome frame by frame
The first version of the capture loop was four lines of Puppeteer:
Here are the four things that we ran into.
1. Page.captureScreenshot races the renderer.
The call returns an image as soon as the compositor is willing to hand one over. That is not the same moment as "layout is done, fonts are loaded, the GSAP tween has committed its final style, and the GPU has finished painting."
You get frames where text hasn't rendered yet, where an SVG fill is still the unanimated default, where a video element is showing its 300x150 default size because metadata hasn't loaded. Every one of these is a frame that renders fine the second time and wrong the first.
We spent longer than we'd like to admit writing "did the frame land" heuristics: poll for fonts.ready, wait for computed styles, compare pixel hashes.
The heuristics works well, although it is not the most robust. This is still the path we use on macOS and Windows, where the fully deterministic alternative isn't available. It's not how you want to run production at scale. But more on that next.
2. HeadlessExperimental.beginFrame gives you that control.
It's a CDP method that runs one layout→paint→composite→screenshot cycle atomically and returns the result:
One call, one frame. The compositor is paused until you ask for the next one.
The response includes hasDamage, which tells you whether anything visually changed since the previous frame. There are no race conditions because there is no concurrent render pipeline still settling in the background.
You seek, you call beginFrame, you get the screenshot image.
Making this work requires a specific Chrome build and a specific set of flags. The binary is chrome-headless-shell, not regular Chrome. The flags are:
Every one of those flags is turning off a source of async scheduling: threaded compositor, threaded scrolling, incremental image decoding, image animation resync, vsync-based surface timing.
With them on, the compositor runs synchronously on the main thread and does not advance until CDP tells it to. With --deterministic-mode the time source is fixed too, so performance.now() is driven by the frameTimeTicks you pass in rather than by the system clock.
Constraint worth stating: this combination works on Linux with chrome-headless-shell.
On macOS and Windows we fall back to Page.captureScreenshot with the "did the frame land" heuristics, because Chrome on those platforms either crashes under --deterministic-mode or has its own issues with the flag combination.
For CI and production renders we run in Docker on Linux. Local dev works anywhere, with lower fidelity on non-Linux.
3. Chrome stops advancing its event loop.
When --enable-begin-frame-control is active, Chrome's main thread stops ticking on its own.
No frame callbacks, no setTimeout, no microtask drain between tasks. Nothing runs until a beginFrame message arrives over CDP.
Which is great for determinism during capture. It is catastrophic during page load, because document.fonts.ready is a promise that resolves on a task, and tasks don't drain if nothing's ticking.
Your GSAP script loads. Your timeline registers on window.__timelines. window.__hf.seek is wired up. And then document.fonts.ready hangs forever.
The fix is a warmup loop. While the page is loading, we fire a beginFrame every 33ms with noDisplayUpdates: true, which advances the event loop without producing a frame:
We kill the loop once window.__hf is ready and fonts have loaded, then start real capture at a frame time past the warmup range so the compositor never sees time going backwards. It's the kind of workaround you only know to write after the first render hangs at "loading fonts" until the timeout fires.
4. Puppeteer's waitForFunction stops working.
The idiom for "wait until the page is ready" in Puppeteer is page.waitForFunction(...). Under the hood, that polls via requestAnimationFrame in the injected world.
rAF doesn't fire in beginFrame mode.
So waitForFunction hangs for the same reason fonts.ready does, except you lose the nice Puppeteer error message and get a generic timeout once the deadline fires.
The fix is to stop using waitForFunction and write the polling loop yourself with evaluate and setTimeout:
Less clever. More obvious. Doesn't depend on anything running inside the page's frame loop.
After those four things, the capture loop that ships today is approximately this:
One seek, one beginFrame, one frame on disk. No retries. No flaky frames. Deterministic.
## The video-in-video problem
Letting a browser play <video> at render time does not work.
In headless mode with BeginFrame on, video decoders skip frames, fail to decode, or sit at readyState: 0 long enough to break the capture deadline.
Even without BeginFrame, different machines and different codec paths produce different output on the same composition. What you see is not what you get.
A <video> on a webpage is happy to drop frames and call it good. A video renderer cannot.
So we took the decoding away from Chrome.
Before capture starts, FFmpeg pre-extracts every <video> in the composition into numbered JPEGs at the target fps.
A 5-second clip at 30fps becomes 150 files.
During capture, for each active video on the current frame, we inject an <img> sibling with the right frame's bytes as a data URI and hide the original <video>:
The interesting part is making the <img> look exactly like the <video> it replaced, so GSAP tweens, CSS transforms, opacity fades, and object-fit rules all keep working.
We read computed styles off the original element and copy them onto the injected image:
From the animation library's perspective, nothing has changed.
The element is in the same place with the same styles. It just happens to show a still image that changes every frame.
We don't let Chrome decode and schedule video. We hand it the exact frame we want, like a flipbook, and take a picture.
Others solve the same problem differently.
Remotion runs a long-running Rust compositor that decodes frames on demand and serves them over HTTP to its <OffthreadVideo> component. Replit demuxes frames in the browser with mp4box.js and decodes through WebCodecs, then paints into a <canvas>.
Ours is the simplest of the three: decode everything ahead of time in FFmpeg, serve JPEGs off disk. We trade flexibility (harder to handle blob URLs, streaming sources, dynamically-set src) for a much shorter pipeline.
There’s lots of improvements we can make here still, but the base works for our case great.
## The other determinism traps
Controlling time and rendering gets you most of the way. It does not get you all the way.
Fonts. Most compositions use Google Fonts via @import url(fonts.googleapis.com/...). That call is a coin flip at render time.
The network might be fast, slow, or blocked. The font might load before or after your first frame.
To get rid of the variance, we rewrite every Google Fonts @import in the compiled HTML to point at a local, base64-embedded copy of the font from @fontsource. The composition renders exactly the same, minus the network round-trip and the flakiness.
Time quantization. A 30fps video has a frame every 33.3333ms.
If the renderer calls seek(0.0333333) for frame 1 and seek(0.0333334) for some edge-case path that recomputes the time, we want those to be the exact same frame. So every seek, in both preview and render, runs through a quantizer:
It's one line. It turns out to matter. Without it, two code paths that compute the same nominal time different ways can produce frames that differ by a pixel, and then you end up staring at a pixel-level diff wondering where it came from.
Rules for the author. No Date.now() in composition code. No unseeded Math.random(). No network fetches at render time.
These are part of the contract. If you violate them, you get nondeterministic output even with everything else we just built.
## What you get
The same window.__hf runtime bundle runs in the studio preview (inside an iframe) and in the headless render.
The renderer verifies a sha256 of the bundle against a manifest before starting, which means "what you see in the preview" is literally the same code that produced your MP4.
Preview and render parity isn't hoped for. It's enforced.
For longer videos, rendering gets split across N Chrome processes.
Each worker renders its share of frames, and FFmpeg concatenates the per-worker MP4 chunks at the end.
The one gotcha: video-heavy compositions can time out in parallel mode because Chrome can't seek multiple <video> elements simultaneously without running out of decoders. T
he fix is to drop back to a single worker for video-heavy renders. Not elegant, but honest.
We didn't invent any of this from nothing.
GSAP's timelines are already paused-and-seekable by design, which is why the adapter is three methods. Remotion proved years ago that HTML could be a video format if you built the authoring model right.
Replit and Vinlic's WebVideoCreator pioneered time virtualization and BeginFrame capture for arbitrary web content.
We took a different path, a constrained authoring model with a seekable runtime contract, but the underlying techniques rest on work other people did first.
## Why we built HyperFrames
We think this is how agents will make video, and we think agents should be able to communicate through video.
That's why we open sourced it.
We want others to help extend this to further possibilities. We welcome contributions to our adapter system.
And we look forward to seeing what products are built on top of HyperFrames.
To try it just run
and tell your agent
> “/hyperframes create me a video about XYZ”.
Repo: github.com/heygen-com/hyperframes.
## 相关链接
- [James Russo](https://x.com/Rames_Jusso)
- [@Rames_Jusso](https://x.com/Rames_Jusso)
- [68K](https://x.com/Rames_Jusso/status/2045230663805317349/analytics)
- [Apr 17](https://x.com/HeyGen/status/2044827454460871072)
- [2.4M](https://x.com/HeyGen/status/2044827454460871072/analytics)
- [performance.now](https://performance.now/)
- [@import](https://x.com/@import)
- [fonts.googleapis.com/](https://fonts.googleapis.com/)
- [@import](https://x.com/@import)
- [@fontsource](https://x.com/@fontsource)
- [Date.now](https://date.now/)
- [github.com/heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)
- [Upgrade to Premium](https://x.com/i/premium_sign_up)
- [3:59 AM · Apr 18, 2026](https://x.com/Rames_Jusso/status/2045230663805317349)
- [68.3K Views](https://x.com/Rames_Jusso/status/2045230663805317349/analytics)
- [View quotes](https://x.com/Rames_Jusso/status/2045230663805317349/quotes)
---
*导出时间: 2026/4/18 10:19:35*
---
## 中文翻译
# HTML 转 Video 并不容易 - 这是我们如何解决的。
**作者**: James Russo
**日期**: 2026-04-16T17:17:10.000Z
**来源**: [https://x.com/Rames_Jusso/status/2045230663805317349](https://x.com/Rames_Jusso/status/2045230663805317349)
---

问题所在
创作视频很难,但大语言模型(LLM)非常擅长编写代码,尤其是 HTML。如果我们能让 LLM 编写代码来生成视频会怎样?
这个切入点很简洁(也很令人兴奋!)。
但现实却并非如此……
大约一年前,我们开始了这段旅程。巨大的提示词,大量的来回沟通,还有大量手工编写的“胶水代码”来填补模型的疏漏。我们添加了一个 Agent 来“手把手”指导模型。这有点用,但仍然无法投入生产环境。
接下来,我们尝试了 Remotion。对于确定性视频(React 组件、优秀的工具链、可投入生产的渲染)来说,它的形态是正确的,但 React 框架总是把 Agent 框得死死的。
我们添加的护栏越多,输出就越保守、越重复。当我们退回到纯 HTML/CSS/JS 时,创造力又回来了。
这就引出了真正的问题:
“我们能保留 HTML 的自由度,同时还能渲染出确定性的 MP4 吗?”
HyperFrames 就是我们的答案:
- 一个极简的创作模型(HTML + data-* 剪辑属性)
- 可插拔的动画运行时
- 一个强制无头 Chrome 每次运行都产生相同像素的渲染管道
浏览器不想做这种事。渲染是线程化且异步的:图片在后台解码,视频在高负载下丢帧,动画跟随显示时钟。所有这些都是为了性能——以及不确定性。
长话短说,我们着手解决了这个问题,而且成功了!
然而,我们也想确保它能良好运行并随模型一起成长。如果一个框架仅仅为了获得可用的输出就需要最大的模型,这就是框架在与 Agent 抗衡的迹象。
我们首先围绕 Gemini Flash 能够可靠编写的内容,构建了最简单版本的 HyperFrames。
从那里开始,我们在不同模型上运行评估,在它们失败的任何地方优化技能和运行时,然后重复这一过程。
目标不是针对某一个模型进行优化。
而是要让创作模型足够简单,Agent 支架足够强大,从而使广泛的模型都能生成可用的合成效果。
我们靠构建 AI 模型和 Agent 谋生。优化 Agent 输出的反馈循环与优化 HyperFrames 的反馈循环是一样的。
我们是如何做到的...
## 唯一的诀窍:seek(定位),而不是 play(播放)
HyperFrames 中的每个合成只向运行时暴露一件事:
渲染器从不调用 `play()`。它调用 `seek(0)`,截屏,`seek(1/30)`,截屏,`seek(2/30)`,截屏,直到获得 300 帧来组成一个 10 秒 30fps 的视频。
时间不会自动前进。没有任何东西由 `requestAnimationFrame` 驱动。浏览器的工作是保持固定帧,直到请求下一帧。
正是这一抽象将两个截然不同的系统坍缩到了同一个代码库中。
工作室预览在 iframe 内运行相同的 `window.__hf`,并通过 postMessage 桥接进行播放/暂停/拖动。
无头渲染通过 Puppeteer 和 CDP 运行相同的 `window.__hf`。当用户拖动时间线时,预览 iframe 调用 seek。当渲染器捕获第 147 帧时,它调用 `seek(147 / fps)`。相同的代码路径。相同的输出。
动画库通过一个只有三个方法的 `FrameAdapter` 插入:
GSAP 是默认选项,因为其时间轴设计上就是可暂停和可定位的。`timeline.pause()` 紧接着 `timeline.totalTime(t, false)` 正是我们需要的确切功能。Lottie、通过 WAAPI 的 CSS 以及 Three.js 时钟也都能轻松适应这种形态。
不适合的是任何试图独占时钟的东西:没有控制器的 CSS 关键帧动画、视频元素,以及大多数运行自己的 `requestAnimationFrame` 的 canvas 库。
对于这些,你要么将它们包装在一个剥夺时钟控制的适配器中,要么将它们离线渲染为帧并作为图像重放。这类似于我们稍后会详细解释的视频处理方式。
## 捕获:逐帧控制 Chrome
捕获循环的第一个版本只有四行 Puppeteer 代码:
以下是我们要解决的四个问题。
1. `Page.captureScreenshot` 与渲染器存在竞态。
该调用在合成器愿意交出图像时立即返回图像。但这与“布局完成、字体加载、GSAP 补间已提交其最终样式、且 GPU 已完成绘制”不是同一时刻。
你会得到文本尚未渲染的帧、SVG 填充仍是未动画默认值的帧,或者因为元数据尚未加载而显示 300x150 默认尺寸的视频元素的帧。这些每一帧都是第二次渲染正确但第一次渲染错误的帧。
我们花了比想象中更长的时间编写“帧是否落地”的启发式算法:轮询 `fonts.ready`,等待计算样式,比较像素哈希。
这些启发式算法效果很好,尽管不是最稳健的。这仍然是我们目前在 macOS 和 Windows 上使用的路径,因为在这些平台上完全确定性的替代方案不可用。这不是你想大规模运行生产环境的方式。但更多相关内容在后面。
2. `HeadlessExperimental.beginFrame` 给了你那种控制权。
这是一个 CDP 方法,原子化地运行一个 布局→绘制→合成→截图 循环并返回结果:
一次调用,一帧。合成器会暂停,直到你请求下一帧。
响应包含 `hasDamage`,它会告诉你自上一帧以来是否有视觉变化。没有竞态条件,因为没有并发渲染管道仍在后台稳定运行。
你定位,你调用 `beginFrame`,你得到截图图像。
使其工作需要特定的 Chrome 构建版本和特定的标志集。二进制文件是 `chrome-headless-shell`,而不是常规的 Chrome。标志如下:
这些标志中的每一个都关闭了一个异步调度的来源:线程合成器、线程滚动、增量图像解码、图像动画重新同步、基于 vsync 的表面计时。
开启它们后,合成器在主线程上同步运行,并且在 CDP 通知之前不会推进。配合 `--deterministic-mode`,时间源也是固定的,因此 `performance.now()` 由你传入的 `frameTimeTicks` 驱动,而不是由系统时钟驱动。
值得说明的约束:这种组合在 Linux 上搭配 `chrome-headless-shell` 有效。
在 macOS 和 Windows 上,我们回退到使用“帧是否落地”启发式算法的 `Page.captureScreenshot`,因为这些平台上的 Chrome 要么在 `--deterministic-mode` 下崩溃,要么对这个标志组合有自身的问题。
对于 CI 和生产渲染,我们在 Linux 的 Docker 中运行。本地开发可以在任何地方进行,但在非 Linux 系统上保真度较低。
3. Chrome 停止推进其事件循环。
当 `--enable-begin-frame-control` 处于活动状态时,Chrome 的主线程停止自行滴答运行。
没有帧回调,没有 `setTimeout`,任务之间没有微任务排空。在通过 CDP 到达 `beginFrame` 消息之前,什么都不会运行。
这对于捕获期间的确定性来说非常棒。但在页面加载期间则是灾难性的,因为 `document.fonts.ready` 是一个在任务上 resolve 的 Promise,而如果没有什么东西在滴答运行,任务就不会排空。
你的 GSAP 脚本加载了。你的时间轴在 `window.__timelines` 上注册。`window.__hf.seek` 接线完成。然后 `document.fonts.ready` 永远挂起。
修复方法是一个预热循环。在页面加载期间,我们每 33ms 触发一次带有 `noDisplayUpdates: true` 的 `beginFrame`,这会推进事件循环而不产生帧:
一旦 `window.__hf` 准备就绪且字体已加载,我们就终止循环,然后在预热范围之外的时间点开始真实捕获,这样合成器永远不会看到时间倒退。这是一种只有当你第一次渲染在“加载字体”处挂起直到超时触发后,才知道要编写的变通方法。
4. Puppeteer 的 `waitForFunction` 停止工作。
在 Puppeteer 中“等待页面准备好”的习惯用法是 `page.waitForFunction(...)`。在底层,它通过注入世界中的 `requestAnimationFrame` 进行轮询。
在 `beginFrame` 模式下 rAF 不会触发。
因此 `waitForFunction` 挂起的原因与 `fonts.ready` 相同,除了你不会收到很好的 Puppeteer 错误消息,而是在截止时间触发后得到一个通用的超时。
修复方法是停止使用 `waitForFunction`,并使用 `evaluate` 和 `setTimeout` 自己编写轮询循环:
不那么巧妙。更加明显。不依赖任何在页面帧循环内运行的东西。
解决了这四个问题之后,今天交付的捕获循环大致如下:
一次 seek,一次 `beginFrame`,磁盘上一帧。无需重试。没有脆弱的帧。确定性。
## 视中视频问题
让浏览器在渲染时播放 `<video>` 是行不通的。
在开启 BeginFrame 的无头模式下,视频解码器会跳帧、解码失败,或者在 `readyState: 0` 停留太久,从而破坏捕获截止时间。
即使没有 BeginFrame,不同的机器和不同的编解码器路径也会在同一合成上产生不同的输出。所见非所得。
网页上的 `<video>` 很乐意丢帧并觉得没问题。视频渲染器则不能。
因此,我们剥夺了 Chrome 的解码权。
在捕获开始之前,FFmpeg 会预先将合成中的每个 `<video>` 以目标 fps 提取为带编号的 JPEG。
一个 30fps 的 5 秒剪辑变成了 150 个文件。
在捕获期间,对于当前帧上的每个活动视频,我们注入一个带有正确帧字节的 data URI 的 `<img>` 兄弟节点,并隐藏原始的 `<video>`:
有趣的部分是让 `<img>` 看起来与它替换的 `<video>` 完全一样,这样 GSAP 补间、CSS 变换、不透明度淡入淡出和 `object-fit` 规则都能继续工作。
我们从原始元素读取计算样式并将它们复制到注入的图像上:
从动画库的角度来看,什么都没有改变。
元素在相同的位置具有相同的样式。只是它恰好显示的是每帧都在变化的静止图像。
我们不让 Chrome 解码和调度视频。我们像翻页书一样交给它我们想要的确切帧,然后拍张照。
其他人以不同的方式解决了同样的问题。
Remotion 运行一个长期运行的 Rust 合成器,按需解码帧并通过 HTTP 提供给其 `<OffthreadVideo>` 组件。Replit 在浏览器中使用 mp4box.js 解复用帧并通过 WebCodecs 解码,然后绘制到 `<canvas>` 中。
我们的方法是三种中最简单的:在 FFmpeg 中提前解码所有内容,从磁盘提供 JPEG。我们用灵活性(更难处理 blob URL、流源、动态设置的 src)换取了一个更短的管道。
在这里我们还有很多可以改进的地方,但基础对我们的情况来说效果很好。
## 其他确定性陷阱
控制时间和渲染能让你完成大部分工作。但不是全部。
字体。大多数合成通过 `@import url(fonts.googleapis.com/...)` 使用 Google Fonts。该调用在渲染时就像是抛硬币。
网络可能很快、很慢或被阻止。字体可能在你的第一帧之前或之后加载。
为了消除这种差异,我们将编译后 HTML 中的每个 Google Fonts `@import` 重写为指向来自 `@fontsource` 的本地 base64 嵌入字体副本。合成渲染完全相同,只是没有了网络往返和脆弱性。
时间量化。30fps 的视频每 33.3333ms 有一帧。
如果渲染器为第 1 帧调用 `seek(0.0333333)`,并为某个重新计算时间的边缘情况路径调用 `seek(0.0333334)`,我们希望这些是完全相同的帧。因此,每次 seek,无论是在预览还是渲染中,都会通过量化器:
只有一行代码。结果证明这很重要。没有它,以不同方式计算相同名义时间的两条代码路径可能会产生相差一个像素的帧,然后你最终盯着像素级差异,不知道它是从哪里来的。
给创作者的规则。合成代码中不要用 `Date.now()`。不要使用未播种的 `Math.random()`。渲染时不要进行网络获取。
这些是契约的一部分。如果你违反它们,即使有了我们刚才构建的所有其他东西,你也会得到不确定的输出。
## 你得到了什么
同一个 `window.__hf` 运行时包在工作室预览(在 iframe 内)和无头渲染中运行。
渲染器在开始前根据清单验证包的 sha256,这意味着“你在预览中看到的”字面上就是生成你的 MP4 的相同代码。
预览和渲染的一致性不是靠希望实现的。是强制执行的。
对于较长的视频,渲染会被拆分到 N 个 Chrome 进程中。
每个 Worker 渲染它份额的帧,FFmpeg 在最后拼接每个 Worker 的 MP4 块。
一个注意事项:视频密集型的合成在并行模式下可能会超时,因为 Chrome 在不耗尽解码器的情况下无法同时定位多个 `<video>` 元素。
修复方法是对视频密集型的渲染回退到单个 Worker。虽不优雅,但诚实。
我们并非凭空发明了这一切。
GSAP 的时间轴设计上已经是可暂停和可定位的,这就是为什么适配器只有三个方法。Remotion 几年前就证明了,如果你构建了正确的创作模型,HTML 可以成为一种视频格式。
Replit 和 Vinlic 的 WebVideoCreator 为任意 Web 内容开创了时间虚拟化和 BeginFrame 捕获。
我们走了一条不同的路,一个受约束的创作模型和一个可定位的运行时契约,但底层技术依赖于其他人首先完成的工作。
## 我们构建 HyperFrames 的原因
我们认为这就是 Agent 将要制作视频的方式,而且我们认为 Agent 应该能够通过视频进行交流。
这就是我们开源它的原因。
我们希望其他人帮助将其扩展到更多的可能性。我们欢迎对我们的适配器系统做出贡献。
我们期待看到有什么产品构建在 HyperFrames 之上。
要尝试它,只需运行
并告诉你的 Agent
> “/hyperframes create me a video about XYZ”(为我创建一个关于 XYZ 的视频)。
仓库:github.com/heygen-com/hyperframes.
## 相关链接
- [James Russo](https://x.com/Rames_Jusso)
- [@Rames_Jusso](https://x.com/Rames_Jusso)
- [68K](https://x.com/Rames_Jusso/status/2045230663805317349/analytics)
- [Apr 17](https://x.com/HeyGen/status/2044827454460871072)
- [2.4M](https://x.com/HeyGen/status/2044827454460871072/analytics)
- [performance.now](https://performance.now/)
- [@import](https://x.com/@import)
- [fonts.googleapis.com/](https://fonts.googleapis.com/)
- [@import](https://x.com/@import)
- [@fontsource](https://x.com/@fontsource)
- [Date.now](https://date.now/)
- [github.com/heygen-com/hyperframes](https://github.com/heygen-com/hyperframes)
- [Upgrade to Premium](https://x.com/i/premium_sign_up)
- [3:59 AM · Apr 18, 2026](https://x.com/Rames_Jusso/status/2045230663805317349)
- [68.3K Views](https://x.com/Rames_Jusso/status/2045230663805317349/analytics)
- [View quotes](https://x.com/Rames_Jusso/status/2045230663805317349/quotes)
---
*导出时间: 2026/4/18 10:19:35*