Originally published on dhseadev.online.
I did not open a video editor to make this. I described a video, and the agent stack I run every day rendered one.
That is a 13-second vertical short about how many items in a Dota 2 inventory are actually sellable. Every frame of it came out of an HTML file.
What HyperFrames is
HyperFrames is HeyGen's open-source HTML-to-MP4 renderer. Apache 2.0, Node 22+, FFmpeg on PATH. Its README is basically three sentences: write HTML, render video, built for agents.
The authoring model is the whole pitch:
- A composition is a plain HTML file. No bundler, no JSX, no build step.
- Timing and track assignment ride on data attributes.
- Motion comes from whatever seekable animation approach you already know — GSAP, native CSS animations, Lottie, Three.js.
- Preview runs in a browser. The final render is headless Chrome driving FFmpeg.
Nothing in that list requires learning a framework, which is exactly why an agent can drive it. The artifact is a file it can read and edit directly, not a component tree it has to reason about through a compiler.
Versus Remotion
Remotion is the obvious comparison and it is a good tool aimed at a different room.
| HyperFrames | Remotion | |
|---|---|---|
| Authoring | HTML + CSS + seekable animation libs | React components |
| Build step | None | Bundler required |
| License | Apache 2.0 | Source-available; paid company license past a size/revenue threshold |
| Agent fit | Plain files an agent reads and edits | Needs React/JSX fluency |
If your codebase is already React, stay in React. If the thing holding the pen is an agent, the no-build option wins on the only axis that counts: how many layers sit between the instruction and the pixels.
Three failures, in the order they cost me
1. The installer hangs. The plain install command opens an interactive multi-select picker. In a normal terminal you press space a few times and move on. In a non-TTY agent shell it never returns, and there is no error to read — the process just sits there. Pass the non-interactive flags and redirect stdin from /dev/null.
2. Chrome is not bundled. The doctor command tells you this immediately, which is the entire argument for running doctor first. It is the cheapest possible way to learn what is missing, and it costs one line. Then fetch the headless shell before you try to render.
3. The CDN fails silently, and this is the real lesson. The scaffold pulls GSAP from a CDN. A render container frequently has no outbound network from Chrome even when the shell does — proxied shell, unproxied browser. The script never loads, the timeline registry is never populated, and the render produces a static or blank video with a passing exit code.
Not a warning. Not a non-zero status. A file that exists, plays, and is wrong.
curl -s -o vendor/gsap.min.js https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js
# then reference ./vendor/gsap.min.js from the composition
Same rule for fonts and images. Local files only, never hot-linked. I had "host media locally" written down as hygiene. It is not hygiene — it is a silent-failure class, and the difference between those two words is whether you find out before or after you publish.
Narration decides the cuts, not the other way around
The instinct is to author the visuals and then lay voice over them. That desynchronises by the third frame, every time, because an estimated duration and a rendered duration are different numbers and the error compounds.
So the order inverts:
- One audio file per sentence, rendered separately.
-
Measure, never estimate.
ffprobe -v error -show_entries format=duration -of csv=p=0 seg.wav. Do not trust a reported length you did not measure. - Concat with a fixed gap. Roughly 0.28–0.32s between segments reads as natural breathing. Less sounds rushed, more sounds like a dropout.
-
Derive cuts cumulatively:
start[i] = start[i-1] + dur[i-1] + gap. Those offsets become each clip's timing attribute. - Mux after render. Render silent, then combine — and verify both streams survived, because a muxed file that lost its audio track still plays perfectly and you will not notice on a machine where you already know what it is supposed to say.
Two runs, measured: eight segments produced 28.90s of video against 28.904s of audio. Nine segments produced 58.26s against 58.26s. That is what deriving from measurements buys you instead of eyeballing.
The part that is not about video
None of the above is hard. All of it is forgettable, which is worse, because a forgotten step here does not fail loudly — it ships a blank video with a green exit code.
So none of it lives in my head. It lives in a skill file: a short instruction document the model loads when the task matches. Mine is deliberately thin — it installs the upstream skill pack and defers to it rather than reimplementing the framework's own workflows badly. What it holds is only the stuff that cost a failed run: the non-interactive install flags, the doctor-first order, the vendoring rule, and the audio-first timing sequence.
Sitting above that is a routing layer that picks which skills load for a task and which checks can block the result before any work starts. I wrote about how that orchestrator layer works separately. One of those checks is the one that matters most here: it will not let me call a video finished because a command exited zero. Both streams present, or it is not done.
The pattern generalises well past video. Every one of those files exists because something failed silently once and I did not want to pay for it twice.
A skill is a receipt for a mistake.
The video itself
The numbers in the short come from Dota Companion, a Chrome extension I build that reads public Dota 2 match data through the free OpenDota API and turns it into things worth knowing about your own play. 833 items in an inventory, 183 of them sellable. It works as a short precisely because it is one number against another number, and that is the shape that survives a vertical crop.
If you try HyperFrames: run the doctor first, vendor everything, generate the audio before you write a single timing value, and verify the output file rather than the exit code. Four rules, and every one of them exists because I broke it first.
Top comments (0)