HTML in, MP4 out: how a Dota short got rendered from a web page

DHSeaDev — Chrome Extensions, Windows Tools, & Idle Games

HyperFrames is an open-source renderer that turns a plain HTML page into an MP4 — no bundler, no JSX, no build step, just a page whose animation happens to be seekable. It is Apache 2.0, it wants Node 22 and FFmpeg on PATH, and its whole pitch is three sentences long: write HTML, render video, built for agents.

That last part is the part that matters here. I did not open a video editor. I described a video, and the agent stack I run every day rendered one. This is the output:

Rendered from an HTML composition by HyperFrames. The numbers are real output from Dota Companion.

What HyperFrames actually is

A composition is an HTML file. Timing and track assignment ride on data attributes. Motion comes from whatever seekable thing you already know — GSAP, native CSS animations, Lottie, Three.js. Preview happens in a browser; the final render is headless Chrome driving FFmpeg. Nothing in that sentence requires a framework to be learned, 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.

The alternative in this space is Remotion, and it is a good tool aimed at a different room. Remotion is React plus a bundler plus a source-available license that turns into a paid company license past a size threshold. HyperFrames is plain files and Apache 2.0. If the 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, which is how many layers sit between the instruction and the pixels.

Three failures, in the order they cost me

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 simply sits there. Pass the non-interactive flags and redirect stdin from /dev/null.

Chrome is not bundled. The doctor command reports it plainly if you run the doctor command, which is the entire argument for running it first. It is the cheapest possible way to learn what is missing, and it costs one line.

The CDN fails silently, and this one 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.

Vendor every library, every font, every image. Local files only, never hot-linked. I had that 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, 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: generate the audio first, one file per sentence, then measure each one with ffprobe rather than trusting the reported length, then derive every cut cumulatively from the measurements plus a fixed gap. Roughly three tenths of a second between segments reads as breathing; less sounds rushed, more sounds like a dropout.

Render silent, mux the voice track in afterwards, and then verify that both streams actually survived — a muxed file that lost its audio stream 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, and nine segments produced 58.26s against 58.26s. That is what deriving from measurements buys you instead of eyeballing.

The HyperFrames render pipeline used for this video A script is split into one narration file per sentence. Each audio file is measured with ffprobe, and the measured durations are what set the data-start timing attributes on the HTML composition. Headless Chrome seeks that composition frame by frame, FFmpeg encodes the frames into a silent video, and the narration track is muxed in last to produce the finished MP4. script, per sentence one WAV per beat ffprobe durations composition.html headless Chrome FFmpeg short.mp4 STEP 1 STEP 2 STEP 3 MEASURED, NOT ESTIMATED STEP 4 STEP 5 STEP 6 MUXED LAST data-start values seek, capture frames encode silent verify both streams Timing flows backwards from the audio. Every cut is a measurement.
The pipeline, drawn the way it actually runs: audio first, timing derived, video last.

The skills that made it one job instead of six

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 the facts live in a skill file rather than in my head. I wrote about the orchestrator layer that routes them a week ago; this is what it looks like when that layer earns its keep on a real deliverable.

  • hyperframes — a pointer layer, deliberately thin. It installs the upstream skill pack and defers to it rather than reimplementing the framework’s own workflow skills badly. What it does hold is the stuff that cost a failed run: the non-interactive install flags, the doctor-then-browser-ensure order, the vendoring rule, and the audio-first timing sequence.
  • orchestrator — picks the category, orders the chain, and names which gates can block the result before any work starts.
  • dota-companion-scheme — the numbers in the video are not decorative. They come from the extension’s own stat contract, and that file is why the script could quote them without me re-deriving anything.
  • verify-before-done — the gate that will not let me say a video is finished because a command exited zero. Both streams present, or it is not done.
  • preship-ritual and wpcom-dhsea-scheme — the pair that got this post onto a live page without breaking it, which on a live WordPress write is its own small discipline.

The pattern generalises past video. Every one of those files exists because something failed silently once and I did not want to pay for it twice. That is the whole method: a skill is a receipt for a mistake.

Why the video is about inventory

Dota Companion is a Chrome extension that reads public Dota 2 match data and turns it into things worth knowing about your own play — improvement streaks measured against your own baseline, not attendance. The inventory number in the short came out of a piece I wrote about counting what a Dota inventory is actually worth: a large pile of items, a much smaller pile you can do anything with. It works as a short precisely because it is one number against another number, and that is the shape that survives a vertical crop.

The extension is on the Chrome Web Store, and the data behind it comes from the excellent free OpenDota API.

What I would tell someone starting today

Run the doctor first. Vendor everything. Generate the audio before you write a single timing value. Verify the output file rather than the exit code. Four rules, and every one of them exists because I broke it first.

HyperFrames lives at github.com/heygen-com/hyperframes. There is a fuller index of everything on this site at the history page, and the builds themselves are on the projects index.

I cross-publish these writeups on dev.to.