I needed two videos for a Chrome extension I built: a promo and a walkthrough. I did not want to open a video editor.
So I used HyperFrames — HeyGen's open-source (Apache 2.0) HTML-to-MP4 framework. Compositions are plain HTML and CSS. No JSX, no bundler, no build step. Timing lives in data attributes; motion comes from GSAP. A headless Chrome seeks the timeline frame by frame and FFmpeg encodes it.
That design matters for one specific reason: an agent can read and edit a plain HTML file. It cannot meaningfully iterate on a timeline in a GUI.
Both videos below were authored as HTML, narrated with a local TTS model, and rendered locally. Here's what came out, then the three things that actually cost me time.
Numbers, for calibration: 1080×1080, 867 frames for the promo, 56 seconds to render. The walkthrough took just over 2 minutes. Both passed the framework's check gate — 0 runtime errors, 0 layout issues, and 22/22 and 23/23 WCAG AA contrast checks respectively.
My render container proxied the shell but not the browser. curl worked. Chrome had no network.
So GSAP never loaded, window.__timelines was never registered, and the render would have produced a static video — with a zero exit code and no error. Nothing in the output says "your animation library is missing."
Vendor everything. Fonts too. This is the failure mode I'd most expect someone else to hit, because it doesn't announce itself — you just get a video where nothing moves, and you assume you wrote the timeline wrong.
My first instinct was to write the storyboard, assign each frame a duration, then generate narration to fit. That's backwards. Speech doesn't land where you guess it will, and the drift compounds — by the third frame the voice is talking over the wrong slide.
Invert it. Generate audio first, one file per sentence, and read the real duration of each:
Then derive every cut point cumulatively — start[i] = start[i-1] + dur[i-1] + gap — and let those offsets become the data-start values. A 0.3s inter-segment gap reads as natural breathing.
Result: a 28.90s video against 28.904s of audio, and 58.26s against 58.26s. No drift, no nudging.
That last check matters — a mux that silently drops the audio stream still produces a file that plays.
The framework is strict that screenshot slots hold real captures, not mockups. Correct rule. But my capture environment couldn't reach the API, so loading the extension gave me an empty UI.
The wrong fix is to mock the interface. The right one is to fetch the real payload out-of-band and seed it through the app's own cache path:
Real data, real render code, real pixels. Two conditions make this legitimate and both are required: the data is genuinely fetched, and the cache shape is one the application actually writes. Invent a shape the app never produces and you're fabricating a screenshot.
One caveat I'd underline: inspect every offline capture. Mine painted the full dashboard but left one field empty. Ship that to a store listing and it reads as a broken product.
The first cut of the promo showed my real stats — including a 0 improvement streak and a loss streak — directly under narration about tracking improvement.
