Skip to content

Instantly share code, notes, and snippets.

@conradcaffier03
Created September 24, 2026 22:50
Show Gist options
  • Select an option

  • Save conradcaffier03/96b1353af45127f728bcb7962f446d6b to your computer and use it in GitHub Desktop.

Select an option

Save conradcaffier03/96b1353af45127f728bcb7962f446d6b to your computer and use it in GitHub Desktop.
Turn an HTML file into a real MP4 — the HyperFrames setup Claude can edit for you (free, open source, ~2 minutes). From @buildwith.conrad

🎬 RENDER — turn an HTML file into a real MP4

Freebie for the RENDER keyword (reel-79A, "This video was a text file"). Deliver as a public GitHub Gist. By the end you have a folder on your machine where an HTML file renders to a finished vertical MP4 — and an AI agent that can edit that file for you.


Why this exists

Claude has no video model. Ask it for a video and you get a description of one. But Claude is very good at HTML, CSS and JavaScript — and HyperFrames turns exactly that into frames. So the agent writes the thing it is already good at, and a renderer does the rest.

The output is deterministic: same file in, same video out, every time. No timeline app, no export dialog, no "please wait, rendering". The video is a file you can diff and version.

What you need (one-time, ~2 min)

  • Node.js 22 or newer — node -v
  • FFmpeg — ffmpeg -version (macOS: brew install ffmpeg)

That is the whole list. Chrome is downloaded automatically on the first render. Check it yourself:

npx hyperframes doctor

Everything with a ✓ is fine. The TTS and music entries may say "Not installed" — those are optional and you do not need them for this.

Step 1 — Scaffold a project (10 seconds)

npx hyperframes init my-video --resolution portrait
cd my-video

--resolution portrait gives you 1080×1920, which is what Reels, Shorts and TikTok want. Use landscape for 1920×1080 or square for 1080×1080.

Heads up: init also installs HyperFrames skills into ~/.claude/skills/ and ~/.agents/skills/ so your coding agent knows the framework. That is what makes the agent part work. If you would rather not have that, set HYPERFRAMES_SKIP_SKILLS=1 before running it.

Step 2 — Write the composition

Open index.html. The whole contract is three things:

  1. A root element with data-composition-id, data-duration, data-width, data-height.
  2. Every animated element carries class="clip" plus its own data-start / data-duration.
  3. One paused GSAP timeline, registered on window.__timelines["<composition-id>"].

That last point is the one that matters. The renderer does not play your animation — it seeks the timeline to each frame and screenshots it. A paused timeline that can be seeked to any point renders correctly. Anything driven by setInterval, requestAnimationFrame or a CSS animation will not.

Here is a complete 6-second starter you can paste over index.html:

<!doctype html>
<html lang="en" data-resolution="portrait">
  <head>
    <meta charset="UTF-8" />
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      * { margin:0; padding:0; box-sizing:border-box; }
      html, body { width:1080px; height:1920px; overflow:hidden; background:#0A101C; }
      #root { width:100%; height:100%; position:relative;
        font-family: ui-sans-serif, system-ui, sans-serif; }
      #ring { position:absolute; left:290px; top:700px; width:500px; height:500px;
        border:10px solid #F0455F; border-radius:50%; opacity:0; }
      #line1 { position:absolute; left:90px; top:1320px; color:#fff;
        font-size:104px; font-weight:800; letter-spacing:-.03em; opacity:0; }
      #line2 { position:absolute; left:90px; top:1450px; color:#F0455F;
        font-size:104px; font-weight:800; letter-spacing:-.03em; opacity:0; }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="6"
         data-width="1080" data-height="1920">
      <div id="ring"  class="clip" data-start="0" data-duration="6" data-track-index="0"></div>
      <div id="line1" class="clip" data-start="0" data-duration="6" data-track-index="1">This file</div>
      <div id="line2" class="clip" data-start="0" data-duration="6" data-track-index="2">is the video.</div>
    </div>
    <script>
      const tl = gsap.timeline({ paused: true });
      tl.fromTo("#ring", { opacity:0, scale:.4, rotate:-90 },
                         { opacity:1, scale:1, rotate:0, duration:1.2, ease:"power3.out" }, 0);
      tl.to("#ring", { rotate:360, duration:4.8, ease:"none" }, 1.2);
      tl.fromTo("#line1", { opacity:0, y:40 }, { opacity:1, y:0, duration:.5, ease:"power2.out" }, .8);
      tl.fromTo("#line2", { opacity:0, y:40 }, { opacity:1, y:0, duration:.5, ease:"power2.out" }, 1.2);
      window.__timelines["main"] = tl;
      tl.seek(0);
    </script>
  </body>
</html>

Step 3 — Check before you render

npm run check

This runs the linter, loads the page in headless Chrome, samples the timeline at several points and reports JS errors, missing assets, layout overflow and text contrast. It takes a few seconds and it will save you a five-minute render of a black screen.

Step 4 — Render

npm run render

or, to pick the filename:

npx hyperframes render -o out.mp4

Measured on an M4 Max, the starter above: 6.0 s of 1080×1920 H.264 at 30 fps, rendered in 5.7 s. Roughly real time. A longer or heavier composition takes proportionally longer.

Step 5 — Let the agent do the editing

This is the actual point. Open the project folder in Claude Code (or Cursor, or any agent that reads skills) and describe the change in words:

Make the headline enter one word at a time, and hold the last frame for half a second.

The agent edits index.html, you run npm run check and npm run render. Because the video is a text file, an agent can change it — and because the render is deterministic, you can review the diff before you ever look at the MP4.


Three traps that cost me a whole evening

I hit all three building the reel you just watched. They share one symptom: the render exits successfully and hands you a finished MP4 with nothing moving in it.

  1. Font paths get rewritten everywhere. The compiler replaces every occurrence of a local font path with an inlined data URI — including inside a JavaScript string. If your script embeds data that happens to contain fonts/, that string is destroyed and the script dies with Unexpected identifier 'data'. Fix: put a zero-width space inside the word (fonts&#8203;/) in embedded data.
  2. </script> inside embedded data ends your script tag. Escape it as <\/script>.
  3. GSAP scales around the element centre, whatever CSS transform-origin says. For a camera wrapper doing a push-in on a screenshot, set it explicitly in the tween: transformOrigin: '0 0'.

How to catch all three in one glance: watch the render output for PAGEERROR, and check that the reported duration matches your composition's data-duration. If the renderer waits 45 seconds and then reports a different duration, your timeline never registered.

What it costs

Nothing. HyperFrames is open source under Apache-2.0 (github.com/heygen-com/hyperframes, 52,287 stars as of 22 Sep 2026). Everything above runs locally. There is a hosted cloud render and a HeyGen account flow, and you never have to touch either one.


Verified end-to-end on 22 Sep 2026: hyperframes 0.8.60, Node v26.8.1, FFmpeg 9.0.1, macOS. init → check → render, no errors, output probed at 1080×1920, h264, 30 fps, 6.000 s.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment