Inside HyperFrames: HeyGen's HTML-Native Video Engine, Audited Line by Line
Code Deep Dives July 31, 2026 📍 Los Angeles, United States Deep Dive

Inside HyperFrames: HeyGen's HTML-Native Video Engine, Audited Line by Line

HeyGen's open-source HyperFrames turns plain HTML into deterministic MP4s and is built for AI agents rather than humans. We indexed all 823,426 lines at commit 89e970f and ran a full static audit: how the virtual-time shim turns Chrome into a camera, why a 3,195-line function absorbs twice the repository's bug-fix rate, the LLM output validator wired into only one of four sibling pipelines, and the twelve published packages that carry no licence metadata at all.

Key Takeaways

HyperFrames is an Apache-2.0 framework from HeyGen that renders plain HTML compositions to deterministic MP4 via headless Chrome and FFmpeg, with twelve seekable animation adapters, 101 lint rules and 39,534 lines of agent skill instructions. A full static audit at commit 89e970f found excellent test discipline (55,283 assertions, zero assertion-free tests) and zero dependency cycles, alongside six validated defects: a 3,195-line runtime function with 73% fix-commit density, an LLM output validator wired into one of four sibling skill pipelines, a determinism shim whose docstring overstates its coverage of setTimeout, randomness seeding that applies only to distributed renders, five byte-identical vendored bundles, and twelve npm packages published with no licence field while the CI guard that checks for one covers a single package.


On 10 March 2026, HeyGen initialised an empty Git repository. One hundred and forty-three days later that repository contained 823,426 indexed lines of code, 3,476 commits from 57 contributors, and 352 tagged releases — a shipped version roughly every ten hours, weekends included. The product is HyperFrames, an Apache-2.0 framework whose pitch fits on one line: write HTML, render video, built for agents.

That premise deserves scrutiny. Rendering video from a browser is not new — Remotion has done it with React since 2021 — but HyperFrames makes a sharper bet. It assumes the author is not a human at all. It assumes the author is a language model, and that a language model writes better HTML than it writes React. Everything downstream of that assumption — the linter, the skill packs, the determinism machinery — follows from it.

We took the repository at commit 89e970f (v0.7.87, 31 July 2026) and put it through a full static audit with Code Indexer, a semantic code-search and analysis engine that builds a queryable graph of a codebase rather than reading files one at a time. The index for HyperFrames holds 69,869 symbols and 312,603 references, resolved into 96,369 static call edges. Every number in this article was computed from that graph or verified directly against the source; every finding was reproduced by hand before it was written down. Where the tooling was wrong, we say so — there is a section devoted to it.

Part I — What HyperFrames actually is

A composition is an HTML file, and nothing else

The unit of work in HyperFrames is a plain .html file. There is no bundler, no JSX, no proprietary timeline format. Timing lives in data attributes on ordinary DOM elements: data-start, data-duration, data-track-index. An element that participates in the timeline carries class="clip". A <video> tag is a video clip; an <audio> tag is an audio track with a data-volume. Open the file in Chrome and it plays — no build step, no dev server required.

This is the entire design thesis, and it has a concrete payoff for agent workflows: the artifact an LLM produces is the artifact that renders. There is no compilation stage where a model's mistake turns into a stack trace from a bundler it has never seen. A malformed composition is malformed HTML, and HTML degrades gracefully.

The cost of that choice is that the browser must be turned into a camera — and a browser is, by construction, the least deterministic environment in mainstream computing.

Turning Chrome into a deterministic camera

The core trick lives in packages/producer/src/services/fileServer.ts, in a function called buildVirtualTimeShim. Before the composition's own scripts run, the local file server injects a prelude that replaces the page's entire notion of time. Date is swapped for a VirtualDate constructor whose prototype chain is preserved but whose now() returns a variable the renderer controls. performance.now is redefined to return the same variable. requestAnimationFrame no longer schedules anything — callbacks are pushed onto a queue that is drained synchronously, on demand, with the virtual timestamp passed in.

The renderer then drives the page one frame at a time by calling __HF_VIRTUAL_TIME__.seekToTime(ms). Every timing-dependent line of JavaScript on that page sees exactly ms until the next seek. Frame 1,847 of a render is not "whatever the page looked like 61.6 seconds after load" — it is a deliberately constructed state at t = 61,566.67 ms, reachable from a cold start in any order.

The distributed path goes further. When frames are farmed out to parallel workers, seedRandomFromFrame switches on a Mulberry32 PRNG that reseeds from the current virtual time on every seek, and crypto.getRandomValues is rewritten to draw from it. Two workers rendering non-adjacent frames of the same composition therefore observe the same random stream at the same timestamp. The seed derivation uses Knuth's multiplicative hash with a golden-ratio offset specifically so that frame 0 does not degenerate — a detail that tells you someone actually watched the first few frames come out wrong.

The HyperFrames render pipeline, reconstructed from the indexed call graph
graph TD
  A["index.html composition"] --> B["Parser: data-* timing, clips, tracks"]
  B --> C["htmlCompiler: inline scripts, subcompositions, assets"]
  C --> D["Local file server"]
  D --> E["Virtual-time shim injected"]
  E --> F["Headless Chrome page"]
  G["videoFrameExtractor: FFmpeg decode"] --> H["Frame lookup table"]
  H --> I["videoFrameInjector: swap video for still frames"]
  I --> F
  F --> J["seekToTime(ms) per frame"]
  J --> K{"Capture mode"}
  K -->|fast path| L["drawElement canvas"]
  K -->|compositor| M["HeadlessExperimental.beginFrame"]
  K -->|ground truth| N["Page.captureScreenshot"]
  L --> O["Blank-frame guard: size vs running median"]
  O -->|anomalous| N
  O -->|ok| P["chunkEncoder / streamingEncoder"]
  M --> P
  N --> P
  P --> Q["FFmpeg mux + audio mix"]
  Q --> R["Deterministic MP4"]

Twelve adapters and a 32-line contract

Freezing time only helps if the animation library agrees to be seekable. HyperFrames solves this with frame adapters — thin shims in packages/core/src/runtime/adapters/ that translate "the composition is now at time T" into whatever the underlying runtime understands. There are twelve of them: GSAP, CSS animations, WAAPI, Anime.js, Lottie, Three.js, TypeGPU, D3, Leaflet, Mapbox, MapLibre and Google Maps. Every single one ships with its own .test.ts beside it.

The GSAP adapter — described in the project's own docs as the primary one — is 32 lines long. That is the strongest architectural signal in the repository: the seek contract is narrow enough that supporting a new animation runtime is an afternoon, not a quarter.

The GPU adapters need more. Three.js and TypeGPU both dispatch a shared hf-seek CustomEvent, deduplicated by exact timestamp so a composition holding both libraries does not pay twice per scrub. The event detail carries a waitUntil() function — a service-worker-style async barrier that lets a listener tell the renderer "do not photograph this frame yet, my texture upload is still in flight." Calling it outside the synchronous listener body throws. There is also a forceDispatchSeekEvent escape hatch that re-fires the same timestamp after video frames are injected, so GPU compositions re-upload textures from images that did not exist a moment earlier.

The video that never plays

Here is the part most people get wrong when they try this at home. HyperFrames does not play <video> elements during a render. It cannot: a media element's playback clock is wall-clock, and wall-clock is exactly what the shim has abolished.

Instead, videoFrameExtractor decodes the source video to still frames ahead of time and builds a lookup table. During capture, videoFrameInjector replaces each <video> with the correct still for the current composition time. Those stills are served through a two-bound LRU cache — bounded by both entry count and total bytes, because a 4K PNG frame is roughly 33 MB once base64-encoded and a count-only cache would happily eat the machine.

The cache carries one of the best comments in the codebase. If a single frame's data URI exceeds the whole byte budget, the entry is refused rather than inserted, because inserting it would trigger the post-insert eviction loop to immediately drop the thing just added — turning the cache into a CPU hot path that re-reads and re-base64-encodes the same frame forever. The authors documented the invariant, the failure mode, and the exact refactor that would silently break the byte accounting. This is not a codebase where someone was guessing.

Three ways to photograph a frame

frameCapture.ts supports three capture modes and picks between them at runtime. screenshot is CDP's Page.captureScreenshot — slow, correct, the ground truth. beginframe drives HeadlessExperimental.beginFrame to advance the compositor deterministically. drawelement is the fast path: it draws the composition element straight to a canvas and reads the pixels back.

The fast path is also the fragile one, and the mitigation is unusually candid. drawElement occasionally returns a blank frame without throwing — a paint-record miss. The code detects this statistically: it tracks a rolling median of the last sixty JPEG sizes and re-captures via screenshot whenever a frame comes back below max(20 KB, median × 0.12). A blank 1080p JPEG is 5–9 KB; a real one is 50 KB to 1 MB. The comment cites the two commits where this was validated — 462 damaged frames down to 0, and 11 down to 0.

The same block records a strategy that was tried and abandoned: proactively screenshotting every clip-boundary frame used to be the default and turned out to be net-harmful, because the injected canvas is unpainted at render start and the "fallback" replaced good frames with white ones. It is now opt-in behind an environment variable. Engineering archaeology like this is rare in public repositories, and it is the sort of thing a code index surfaces immediately and a casual browse never does.

The agent interface is 39,534 lines of Markdown

HyperFrames ships nineteen agent skills totalling 39,534 lines of Markdown — an instruction corpus comparable in size to a mid-sized application. One skill, /hyperframes, is a router that confirms the brief and dispatches to a workflow: /product-launch-video, /faceless-explainer, /pr-to-video, /embedded-captions, /talking-head-recut, /motion-graphics, /music-to-video, /slideshow, /general-video, plus /remotion-to-hyperframes for porting existing React compositions.

Backing them is a registry of 113 installable blocks and 25 components, and a domain linter with 101 distinct rules across nine modules — composition structure, GSAP usage, captions, media, fonts, adapters, textures, slideshows. hyperframes lint is static; hyperframes check opens headless Chrome and gates on runtime errors, layout, motion and WCAG contrast. The regression suite pins roughly 240 MB of golden MP4 baselines in Git LFS.

Taken together, the linter and the skills are the real product. The renderer is the easy half.

Part II — The audit

Method, and why a chat window cannot do this

A conventional LLM code review reads files. At 823,426 lines that is not a strategy, it is a sampling exercise. Code Indexer works differently: it embeds and indexes every chunk, resolves the symbol graph, and then runs graph algorithms over it — Personalised PageRank to find load-bearing symbols, Louvain community detection to find architectural bridges, and a git-history join to find files where churn, complexity and bug-fix frequency coincide.

The partition it produced for HyperFrames has 4,048 communities at modularity Q = 0.534 — a genuinely modular graph, not a monolith wearing a monorepo costume. Cycle detection found zero function cycles and zero file cycles. That is a strong result for a codebase this young and this fast-moving.

Static analysis over an indexed code graph — no code was executed, runtime hot paths are invisible, and heuristic findings are prioritisation signals, not verdicts.

— Code Indexer, truth boundary attached to every report

We treated that boundary seriously. Each finding below was reproduced against the source before publication, and three whole classes of tool output were rejected as noise. Those rejections are documented later in this article, because an audit that only reports its hits is marketing.

# Finding Location Severity How it was verified
1 One function of 3,195 lines; cyclomatic 658, cognitive 1,378; 73% fix-commit density <code>packages/core/src/runtime/init.ts:117-3311</code> High Line range opened; <code>git log</code> fix-prefix ratio counted
2 LLM-worker output validator wired into 1 of 4 sibling assemblers <code>skills/*/scripts/assemble-index.mjs</code> High 6-line diff; filesystem check for <code>lib/frame-contract.mjs</code>; co-change history
3 Determinism shim docstring claims setTimeout coverage it does not implement; linter does not cover it either <code>packages/producer/src/services/fileServer.ts:216-394</code> Medium No <code>window.setTimeout</code> assignment or <code>defineProperty</code> in production code; lint rule list read
4 Seeded randomness applies only to the distributed render path <code>packages/producer/src/services/distributed/renderChunk.ts</code> Medium Every <code>seedRandomFromFrame</code> call site enumerated in the index
5 Five byte-identical copies of a 35,008-byte vendored bundle <code>registry/blocks/*/lib/liquid-glass.iife.js</code> Low MD5 comparison across all five files
6 Twelve published packages carry no licence field; CI guard covers one <code>scripts/verify-packed-manifests.mjs</code> High (adoption) Live npm registry queried at v0.7.87; guard's early-return read

Finding 1 — the 3,195-line function named "Modular"

Ranked by cognitive complexity, the hardest function in HyperFrames is not close to second place. initSandboxRuntimeModular() in packages/core/src/runtime/init.ts opens at line 117 and closes at line 3311. It is one function of 3,195 lines, with a cyclomatic complexity of 658 and a cognitive complexity of 1,378 — 1.7× the next-worst function in the repository and roughly 92× SonarSource's recommended ceiling of 15.

It is also the browser-side runtime bootstrap: the code that installs the player, the media layer, the adapters and the teardown hooks inside every single page the renderer photographs. There is no code path in the product that does not run it.

The git history confirms what the structure predicts. Of the 120 commits that touched init.ts in the last six months, 88 — 73% — are prefixed fix. The repository-wide fix rate over the same 3,476 commits is 38%. init.ts absorbs bug fixes at roughly twice the baseline rate, and it has done so consistently since March.

Source: git log, last 6 months, commit 89e970f — conventional-commit prefixes

The name is the tell. initSandboxRuntimeModular was almost certainly the modular replacement for something worse, and then it grew. This is the single highest-leverage refactor in the repository: splitting it along the seams it already has — analytics bridge, player install, media layer, adapter registration, teardown — would move the project's worst maintainability number and its worst defect concentration at the same time.

Source: Code Indexer cognitive-complexity ranking at commit 89e970f

Finding 2 — the guardrail wired into one pipeline out of three

This is the finding we would not have gone looking for, and it is the one that matters most.

Code Indexer's implicit-coupling analysis mines git history for files that always change together without importing each other. Top of the list, at a coupling ratio of 1.0 across seven co-changes: skills/faceless-explainer/scripts/assemble-index.mjs and skills/pr-to-video/scripts/assemble-index.mjs. Two files, different skills, identical churn.

They are near-identical clones — 620 and 626 lines, differing by exactly six lines. Those six lines are an import of ./lib/frame-contract.mjs and a call to validateFrameHtml() wrapped in a die()-on-failure block. A filesystem check settles the rest: frame-contract.mjs exists in exactly one place in the repository, skills/pr-to-video/scripts/lib/. There are four copies of assemble-index.mjs across the skill tree. Only one of them validates.

What that validator checks is not cosmetic. It is the contract between an LLM sub-agent and the assembler. It rejects worker output that is a full HTML document instead of a bare <template> fragment; output containing more than one template, or markup after the closing tag; a root element with no data-composition-id, or one that does not match the frame the assembler asked for; and a root whose data-duration is missing, non-numeric, zero or shorter than the storyboard expects.

In /pr-to-video, an LLM worker that returns a wrapped document or drifts a composition ID fails loudly at assembly time. In /faceless-explainer and /product-launch-video, running the same architecture through the same assembler, that output is accepted and flows into the composition. Git shows the two files were built together in PR #1778 — "rebuild faceless-explainer + pr-to-video on the shot-sequence architecture" — and have moved in lockstep ever since. The divergence is drift, not design.

The fix is small: lift lib/frame-contract.mjs into a shared location and wire it into the other three assemblers. The reason it went unnoticed is instructive — no import graph connects these files, no linter compares them, and no human reads four 600-line sibling scripts side by side. It took a join between the code index and the commit log to see it at all.

Skill assemble-index.mjs Imports frame-contract Malformed worker output
/pr-to-video 626 lines Yes Hard failure at assembly
/faceless-explainer 620 lines No Accepted silently
/product-launch-video 768 lines No Accepted silently
/music-to-video 220 lines No Accepted silently

Finding 3 — the shim documents a guarantee it does not implement

The JSDoc above buildVirtualTimeShim states that it "freezes Date.now, performance.now, and the rAF/setTimeout pipeline." Two of those three are true. Date and performance.now are replaced; requestAnimationFrame and cancelAnimationFrame are replaced. setTimeout, clearTimeout, setInterval and clearInterval are captured into local variables and re-exported on __HF_VIRTUAL_TIME__ for the renderer's own use — but the page's window.setTimeout is never swapped.

We checked this two ways. There is no assignment to window.setTimeout anywhere in production code — the only match in the repository is inside a test that builds a mock context — and no Object.defineProperty(window, "setTimeout", …) either. The shim defines exactly two properties on window: Date, and performance.now.

The consequence is bounded but real: a composition that drives motion with setTimeout or setInterval still runs on wall-clock during a render, while everything around it is frozen at the virtual timestamp. And the linter does not catch it. The non_deterministic_code rule flags Math.random(), Date.now(), new Date(), performance.now(), crypto.getRandomValues(), gsap.utils.random() and GSAP's string-form "random(...)" tween values — a thoughtful list — but timer-driven animation appears on neither the shim's list nor the linter's. It is the one non-determinism vector in the framework that is neither virtualised at runtime nor rejected at lint time.

Finding 4 — determinism is path-dependent

The README states the guarantee without qualification: "Deterministic: same input, same frames, same output." The seeded-PRNG block that makes randomness reproducible is gated behind seedRandomFromFrame, which defaults to false. Searching every call site in the index returns exactly one production caller that passes true: packages/producer/src/services/distributed/renderChunk.ts.

Local, in-process renders therefore leave Math.random and crypto.getRandomValues native. A composition using randomness is byte-reproducible on the distributed path and not reproducible run-to-run on the local one. The source comment is explicit that this is deliberate — it preserves "the in-process renderer's non-deterministic behaviour" — and the linter's intended posture is that you should not use randomness at all. But the README's blanket claim and the implementation's scoped one are not the same sentence, and a team building CI regression tests on local renders would want to know which they are relying on.

Finding 5 — 175 KB of the same file, five times

Five registry blocks — ios26-liquid-glass, liquid-glass-context-menu, liquid-glass-media-controls, liquid-glass-notification and liquid-glass-widgets — each vendor a lib/liquid-glass.iife.js. All five are byte-identical: the same MD5, the same 35,008 bytes. Any upstream fix to that bundle has to land five times, and nothing in the build enforces it. This is the cheapest finding in the report and the easiest to fix.

Finding 6 — twelve published packages with no licence on npm

This one we found by pulling the thread on licensing, and it is the finding most likely to block a real adoption decision.

HyperFrames is Apache-2.0. The LICENSE file at the repository root is the standard 190-line text, and CREDITS.md states plainly that all code is "independently implemented and distributed under the Apache 2.0 License." That is the project's headline advantage over Remotion, whose source-available licence charges companies above a seat threshold.

The repository's intent is not in doubt. Its published metadata is another matter. Of the fourteen workspace packages, exactly one — packages/cli — declares "license": "Apache-2.0" in its package.json. The other thirteen declare nothing, and only one of those is marked private.

We checked the live npm registry rather than trusting the repo, in case the publish pipeline injects the field. It does not. At version 0.7.87, the hyperframes CLI package publishes with license: Apache-2.0. @hyperframes/core, @hyperframes/engine, @hyperframes/producer, @hyperframes/player and @hyperframes/sdk all publish with no licence field at all.

That matters practically, not pedantically. Every enterprise software-composition-analysis tool — Snyk, FOSSA, Black Duck, GitHub's own dependency review — resolves a missing license field to "unknown", and "unknown" is a hard gate in a great many procurement policies. A developer who installs hyperframes gets a clean Apache-2.0 signal; a developer who depends on @hyperframes/core directly, as anyone embedding the renderer would, gets a scanner warning that their legal team has to clear by hand.

The sharpest detail is that the repository already has the guard. scripts/verify-packed-manifests.mjs contains a function called verifyCliLicense that throws if the source manifest does not declare Apache-2.0 and throws again if the packed tarball fails to preserve it. Its first executable line is if (workspace !== "packages/cli") return;. The check runs in CI, works correctly, and exempts thirteen of the fourteen packages by name. Widening that guard is a one-line change; adding the field to thirteen manifests is a five-minute one.

Package Repo manifest Published on npm (v0.7.87) Covered by CI licence guard
<code>hyperframes</code> (CLI) Apache-2.0 Apache-2.0 Yes
<code>@hyperframes/core</code> not declared no licence field No
<code>@hyperframes/engine</code> not declared no licence field No
<code>@hyperframes/producer</code> not declared no licence field No
<code>@hyperframes/player</code> not declared no licence field No
<code>@hyperframes/sdk</code> not declared no licence field No

Part III — What the audit got wrong

Three signals the tooling produced would have been misleading if published unexamined. We are reporting them because a rejected finding is as much a result as a confirmed one.

The 374 "critical" security findings are not vulnerabilities. The audit's own confidence tiering resolves them as 0 proven, 0 likely, 332 hypothesis — every one a static heuristic with no data-flow corroboration. The engine floors the Security category score in that situation and labels it "unconfirmed-only … triage before treating as verdict." Filtering to actionable confidence returns an empty list. The honest statement is: static analysis found no confirmed vulnerability in HyperFrames, and a pile of pattern matches that a human should sample.

The SEO grade of F is a category error. The SEO scanner found 4,596 issues across 612 HTML files and scored zero in every bucket. Those HTML files are video compositions. They have no meta descriptions because they are not pages; their heading hierarchy is typographic, not semantic; their images are animation frames. A web-app-shaped analyser pointed at a video framework produces web-app-shaped complaints. The same caveat applies more gently to the overall grade, which is computed against a "WebApp" baseline.

Most of the self-admitted-technical-debt hits are prose. The scanner counted 100 SATD markers, 68 tagged BUG. We sampled and both hits were explanatory comments that merely contain the word: portUtils.ts describes "the devbox class of bug where a port is free on 127.0.0.1 but held on 0.0.0.0 via SSH forwarding", and scaffolding.ts explains that "writing index.html here caused a double-audio bug." Both are documentation of fixed problems, not admissions of debt. The TODO (21) and XXX (10) counts are more trustworthy, and even there several XXX hits are GLSL shader source inside a minified block.

Part IV — Quality profile

Set the letter grade aside and read the categories. HyperFrames scores 53/100 on a scale calibrated for enterprise production readiness — which places it in the 93rd percentile against a corpus of 64 audited open-source developer tools, whose median is 44. Mature open-source projects routinely land in the C–F band on this scale; the percentile is the defensible number.

Source: Code Indexer peer benchmark, corpus v2-2026-07-10 (TypeScript / General bucket)

The test discipline is the standout. 923 test files hold 15,843 test functions and 55,283 assertions — an average of 3.49 assertions per test, with zero assertion-free tests. The test-to-source file ratio is 63% against a corpus median of 34%. Every one of the twelve frame adapters has a dedicated test file. The category scores a perfect 15/15.

There are test smells underneath that volume — 1,558 assertion-roulette cases (multiple bare assertions in one test, so a failure does not say which), 1,088 duplicate assertions and 919 eager tests. On a suite this large those are hygiene items, not alarms, but the AWS Lambda and CLI auth suites concentrate them.

Dead code sits at effectively 0% of the codebase — 19th percentile, where low is good. Magic numbers run at 0.0 per KLOC. Documentation coverage of 22% of symbols sounds thin until you see the corpus median of 7%; on this axis HyperFrames is at the 100th percentile, and the comment quality in the engine is the best evidence in this article.

Category Score Reading
Test coverage 15 / 15 63% test-to-source, 55,283 assertions, zero assertion-free tests
Code quality 12 / 15 47 complex functions, ~0% dead code, 30 deep-nesting sites
Hygiene 9 / 15 0 typos, 0 deprecated APIs, SATD median age 31 days
Security 10 / 20 Floored — all findings unconfirmed heuristics, 0 proven
Maintainability 5 / 15 57 god files, 14 god modules, duplication across registry and skills
Documentation 4 / 10 1,121 of 4,988 symbols documented — still p100 vs peers
Architecture 2 / 10 0 cycles, but 20 coupling hotspots and 20 low-cohesion files

The human risk

The bus factor is 2. Fifty-seven people committed to HyperFrames in six months, but losing the top two primary owners would orphan more than half of the 3,952 analysed files. One contributor is primary author on 1,831 files across 8,748 commits; the second on 1,055 files. The knowledge islands — files with a single author and active churn — cluster in two places: the Studio editor UI and the prompting documentation that teaches agents how to use the framework.

Source: git log and tag creation dates, repository initialised 2026-03-10

The shape of that chart is worth a sentence. Commit volume more than doubled in July while the contributor count fell from its May peak — the work concentrated rather than spread. Release cadence peaked in April–May at roughly three tags a day and has been easing since, which usually means a project moving from land-grab to consolidation.

Where the blast radius lives

Personalised PageRank over the call graph names replace in packages/parsers/src/gsapInline.ts as the most depended-on symbol in the tree, with 897 references reaching into it. The GSAP parsing layer is worth pausing on: gsapParserAcorn.ts and gsapWriterAcorn.ts together run to over 4,000 lines of Acorn-based static analysis and source rewriting, whose job is to let Studio edit keyframes in a composition's actual JavaScript and write the change back. That is the machinery that makes a hand-written GSAP timeline behave like a data structure — and it is why the parsers package shows up in both the hot-symbol ranking and the refactor shortlist.

Louvain analysis flags run, result, track, basename and createElement as the top architectural bridges — run alone touches 265 communities with a degree of 473. Several of those are generic names that a graph algorithm will always over-weight, which is a fair criticism of the signal; createElement at 93 communities and requestAnimationFrame at 47 are the ones that genuinely mark subsystem seams.

Part V — The licence, and what it actually constrains

Apache-2.0 is the loudest thing on the HyperFrames README and the quietest thing in most write-ups about it. It deserves a section, because it is simultaneously the project's strongest competitive claim and the place where its dependency graph gets complicated.

What Apache-2.0 gives you

Apache-2.0 is permissive with teeth. You may use, modify, sublicense and commercialise the code, including in closed-source products, with no copyleft obligation on your own work and no royalty on output. Crucially, §3 grants an explicit, irrevocable patent licence from every contributor — something MIT and BSD do not do — with a termination clause that revokes it if you sue a contributor for patent infringement over the work. For a video codec-adjacent domain, that patent grant is not decorative.

The obligations are light and non-negotiable: keep the licence text and copyright notices in any redistribution, state significant changes you made to the files, and propagate any NOTICE file. HyperFrames ships no NOTICE file, which is correct rather than sloppy — Apache-2.0 only requires you to propagate one that exists. Trademarks are explicitly *not* granted, so "HyperFrames" and HeyGen's marks stay theirs however you fork the code.

Set against Remotion — the acknowledged prior art, credited by name in CREDITS.md — this is the material difference. Remotion is source-available under its own licence, which requires a paid company licence above a small-team threshold. HyperFrames charges nothing, gates nothing behind seat counts, and imposes no per-render fee. If you are rendering at volume, that is the entire commercial argument, and it is a real one.

Where it gets complicated: the dependency graph

The framework's own licence is only half the picture. Rendering video means shelling out to FFmpeg, and FFmpeg's licensing depends on how it was built.

For local renders, HyperFrames requires FFmpeg to be installed on the machine — it is listed as a prerequisite alongside Node 22+. That is the clean arrangement: the framework invokes a binary you already had, and nothing is redistributed. The AWS Lambda package is different. packages/aws-lambda/package.json declares ffmpeg-static, which publishes on npm under GPL-3.0-or-later. Deploying that stack bundles a GPL-3.0 binary into your Lambda artifact.

Invoking a separate binary over a process boundary is the conventional arm's-length arrangement and does not make your own code GPL. But redistribution is a different question from linkage: if that deployment artifact ever leaves your organisation — shipped to a customer, baked into a product image — GPL-3.0's source-offer obligations attach to the FFmpeg binary inside it. Internal deployments are the common case and are unproblematic. It is worth five minutes of your counsel's time before it becomes a product.

The second wrinkle is GSAP. It is the primary animation adapter, the one every quickstart uses, and it is declared as a dependency of @hyperframes/studio, @hyperframes/player and the SDK playground. GSAP is not open source. Its npm licence field reads "Standard 'no charge' license" and points at gsap.com — free for the overwhelming majority of uses, genuinely generous since Webflow opened up the plugin set, but not OSI-approved and governed by its own terms rather than a licence you already have policy for. A composition that uses GSAP inherits that, whatever HyperFrames itself is licensed under.

The rest of the graph is unremarkable and correctly credited: puppeteer-core is Apache-2.0, ffprobe-static and onnxruntime-node are MIT, and mediabunny is MPL-2.0 — the one third-party licence CREDITS.md calls out explicitly, which it should, since MPL-2.0 carries file-level copyleft.

Component Licence Practical constraint
HyperFrames itself Apache-2.0 Commercial use, closed forks, no per-render fee; keep notices, patent grant included
<code>puppeteer-core</code> Apache-2.0 None beyond attribution
FFmpeg (local render) System install You supply it; nothing redistributed
<code>ffmpeg-static</code> (Lambda path) GPL-3.0-or-later Binary is bundled into the deployment artifact — source-offer duties on external redistribution
GSAP (primary adapter) Standard 'no charge' licence Not OSI-approved; governed by GSAP's own terms, not Apache-2.0
<code>mediabunny</code> (Studio) MPL-2.0 File-level copyleft on modified MPL files
<code>ffprobe-static</code>, <code>onnxruntime-node</code> MIT None beyond attribution

None of this is a criticism of the licence choice, which is the right one and the reason the project will be adopted. It is an argument that "HyperFrames is Apache-2.0" is the beginning of the compliance conversation rather than the end of it — and that the thirteen packages publishing without any licence metadata at all make that conversation harder than it needs to be.

Verdict

HyperFrames is a serious piece of engineering shipped at an unusual speed, and the two facts are related in both directions.

What is genuinely good here is not the headline idea — HTML instead of React is a positioning choice — but the execution underneath it. The virtual-time shim is careful. The adapter contract is narrow enough that twelve runtimes fit behind it and the primary one is 32 lines. The video-frame injector's cache reasons about its own failure modes in writing. The capture layer documents a strategy that was tried, measured and reverted, with commit hashes. Zero dependency cycles across 4,048 communities at five months old is not an accident. And 55,283 assertions with no assertion-free test is a team that means it.

The debt is concentrated and legible. One function carries 3,195 lines and twice the baseline defect rate. A validator that exists to catch LLM sub-agent errors is wired into one of four sibling pipelines. The determinism story has two documented gaps — a timer pipeline the shim's own comment claims to cover and does not, and a randomness guarantee that holds on the distributed path and not the local one. Twelve published packages carry no licence metadata while a CI guard that would catch it early-returns for all but one of them. None of these is architectural. All of them are the predictable residue of 352 releases in 143 days, and all of them are a week of work to close.

For teams evaluating it: the framework is production-grade for the case it was built for — agent-authored, deterministically rendered video with no build step. Pin your compositions to seekable adapters rather than timers, keep Math.random out of them, and treat the local render path as reproducible only insofar as your composition is. The linter will enforce most of that for you, which is more than most frameworks can say. If you are going through procurement, budget an hour for the licence conversation: the framework is cleanly Apache-2.0, but the Lambda path bundles a GPL-3.0 FFmpeg binary and the primary animation adapter is not open source at all.

Methodology. Every figure in this article was computed by Code Indexer over the repository at commit 89e970f — 69,869 symbols, 312,603 references, 96,369 call edges — or measured directly with git. Findings 1 through 6 were each reproduced against the source by hand: line ranges opened, files diffed, MD5s compared, call sites enumerated, and the licence metadata checked against the live npm registry rather than the repository. Three tool signals were rejected as false positives and documented above. No code was executed; runtime behaviour is outside the boundary of this analysis, and nothing here is legal advice.

Update — what a second, independent audit found

After publication, an independent review of HyperFrames and Remotion — written from the standpoint of a solo operator building a short-form vertical video pipeline — surfaced a class of limitation this audit did not look for. It reads the registry and the skill scripts as a production toolkit rather than as a codebase. Every claim below was re-verified against the same commit, 89e970f, and two of its figures were adjusted in the process.

The registry is almost entirely landscape. Of the 113 installable blocks, 111 hard-code data-width="1920". Exactly three declare a vertical 1080: tiktok-follow, instagram-follow and spotify-card. A fourth candidate, flowchart-vertical, is vertical in layout but still declares a 1440 canvas, so the independent report's count of four vertical blocks is one too generous — the true figure is three. The same pattern runs through the components: of the sixteen caption components, fifteen hard-code 1920 or 1080px somewhere in their markup, caption-blend-difference being the sole exception.

That matters because it inverts the article's framing of the catalog. We described 113 blocks and 25 components as evidence that the linter and the skills, not the renderer, are the real product. They are — but the catalog behind them is built for 16:9, and a team pointing this framework at Reels or TikTok inherits a porting task the block count does not advertise.

There is a sharper instance of the same thing in the caption pipeline, and it is a genuine defect. skills/product-launch-video/scripts/lib/dimensions.mjs defines CAPTION_BAND_FRACTION = 0.1667 and captionBand() places the subtitle band in the bottom 16.67% of the frame. The project's own reference material contradicts that: skills/embedded-captions/references/aesthetic-principles.md states that for 9:16 TikTok, Instagram and Shorts the caption zone is y between 12% and 78%, because the bottom 22% is platform UI. The default band therefore sits entirely inside the region the project itself marks as covered by the platform's own buttons. The correct number is documented; it never reached the pipeline code.

And that constant is not defined once. Identical copies of dimensions.mjs carrying the same CAPTION_BAND_FRACTION = 0.1667 exist in product-launch-video, pr-to-video and faceless-explainer, with a fourth workflow calling captionBand. Readers of Finding 2 will recognise the shape: the same sibling-clone topology that left the frame-contract validator wired into one of four assemblers also governs the caption geometry. Fixing the safe zone means fixing it in three places, and nothing in the build enforces that they agree.

The independent audit also reports, from an actual render on its own machine, that the portrait preset changes the canvas without reflowing the layout — text clipped at the edges — and that hyperframes check caught it honestly as container_overflow, panel_out_of_canvas and text_occluded, but at warning and info level, so the render was not blocked. That is consistent with what we found in the linter's design and is the practical counterpart to it: the gate exists, correctly identifies the problem, and by default lets it through. Running check --strict is the difference between a diagnostic and a gate. We did not run a render and cannot independently confirm the clipping; the mechanism, however, is exactly what the code predicts.

It is worth stopping on that, because it is the most generalisable thing this series has turned up. HyperFrames now has two confirmed defects produced by the same mechanism: a validator wired into one of four sibling assemblers, and a safe-zone constant declared three times with a value its own documentation contradicts. Both live in copied scripts under skills/*/scripts/. Neither pair imports the other, so no import graph and no call graph connects them; they are related only by filename and by the shape of what is inside. That is precisely the blind spot in every tool that reasons over a dependency graph — and it is the reason both defects survived in a repository with 101 lint rules, 55,283 assertions and a browser-based runtime gate.

The two are not the same kind of divergence, which is the detail that matters if anyone wants to mechanise this. In the assembler case a guard clause was present in one sibling and absent in the others. In the caption case every sibling is byte-identical and the problem is the value they all agree on. A detector that groups files by basename and diffs their guard clauses catches the first and sails past the second; it has to compare declared constant literals as well. And the honest footnote is how each was actually found: the first fell out of a git co-change join, the second only because someone audited the same repository from a completely different angle — asking whether it could ship vertical video rather than how it was built. Neither of those is a detector. Both should be.

For anyone maintaining a repository shaped like this one — parallel workflow directories, each with its own copy of a shared script — the cheap defence is a test like the one Remotion runs for exactly this class: regenerate the siblings from a single source and assert they match. Remotion keeps two byte-identical 104-line package lists in sync that way, in packages/it-tests/src/monorepo/package-sync.test.ts. HyperFrames has four assemblers and three dimension modules and no such test. That asymmetry, more than any single defect, is what the comparison between these two codebases actually turns on.

📚 Sources & References

# Source Link
[1] HyperFrames — open-source HTML-native video rendering framework HeyGen, 2026 github.com
[2] Code Indexer — semantic code search and analysis engine AWM Lab, 2026 codeindexer.dev
Share X Reddit LinkedIn Telegram Facebook HN