Collegica Collegica Collegica
  • Subjects
    • Robotics
    • Software development
    • AI
    • Finance
    • Aging well
  • Events
  • Talks
  • About
  • Newsletter

Videos from HTML

How the four narrated explainers on this site were made — HyperFrames compositions and Kokoro narration, rendered on a laptop for nothing — with the pipeline timed and the rules that keep a render honest

Author

Behzad Samadi

Published

September 13, 2026

That is a hundred seconds of video: seven scenes, a narrator, a rate chart drawn from real data, a number counting up to $48,935. It was made in an afternoon, by an agent, from HTML. It cost nothing to render and nothing to narrate, and the same folder will render it again tomorrow. There are three more like it on this site. This guide takes one apart, re-runs the pipeline with a stopwatch, and lists the handful of rules that make the difference between a video that renders and one that only previews.

NoteTL;DR
  1. A video is a folder of HTML. HyperFrames renders compositions — HTML, CSS, a GSAP timeline — to MP4 by seeking to each frame’s exact time and capturing it. data-start and data-duration on an element are the whole timing model.
  2. The narrator is an 82-million-parameter model on your disk. Kokoro, Apache-licensed weights, 352 MB cached. Two sentences took about a second to synthesise once the files were there; it reads “Collegica” wrong, and the fix is a respelling in the script, never on screen.
  3. Measured on a 24 GB Mac: the checks in 16 s; a 100-second, 3,013-frame render in 2 min 10 s on the safe profile; 4.3 MB out. A re-render is not byte-identical to the shipped file — frames are deterministic, the encoder’s bytes are not.
  4. The rules that matter are about cold seeks. A render worker jumps straight to a frame; anything whose value depends on what ran before — a fromTo without a visible end state, a relative tween, geometry measured in a callback — looks right in the preview and wrong in the file. The linter has a code for each.
  5. Every number on screen came from the article’s own simulator. Not retyped. If a figure is wrong, the code is wrong, and the code is the thing you can check.

The shape of it

A HyperFrames project is a folder. Everything the video is lives in HTML files an agent can write and a person can read:

video/fixed-or-variable/
├── index.html                  # the root: seven scenes and seven narration clips on one timeline
├── compositions/frames/
│   ├── 01-hook.html            # one self-contained composition per scene
│   ├── 02-rate-path.html
│   └── … 07-close.html
├── audio/frame-01.wav … 07     # Kokoro narration, one WAV per scene
├── assets/                     # the article's own hero, kept for reference
├── package.json                # pins [email protected] so it renders the same next year
└── README.md                   # how to rebuild, and what is peculiar to this one

The root is where time is decided. Each scene is a div with a data-composition-src pointing at its file, a data-start and a data-duration; each narration clip is an <audio> element with the same two attributes, offset three tenths of a second so the picture leads the voice:

<div id="el-03" class="scene" data-composition-id="03-2021"
     data-composition-src="compositions/frames/03-2021.html"
     data-start="26.53" data-duration="15.11"></div>
<audio id="el-03-voice" src="audio/frame-03.wav"
       data-start="26.83" data-duration="13.61" data-volume="1"></audio>

There is no editor state anywhere else. That is the point HyperFrames’ concepts page makes first — “one folder of HTML is the whole video” — and it is why an agent can make one: it is writing files it already knows how to write.

One scene, read closely

Scene three of the finance video puts two rates on screen and counts up a verdict. The composition is a <template> holding a <style>, the markup, and a script:

<div id="root" data-composition-id="03-2021" data-start="0" data-duration="15.11"
     data-width="1920" data-height="1080">
  <div id="f03-date" class="clip f03-date" data-start="0" data-duration="15.11">Signed September 2021</div>
  <div id="f03-cards" class="clip f03-cards" data-start="0" data-duration="15.11">
    <div class="f03-card fx" id="f03-c1"><div class="lbl">Five-year fixed</div><div class="rate">1.69%</div></div>
    <div class="f03-card vr" id="f03-c2"><div class="lbl">Variable</div><div class="rate">0.98%</div></div>
  </div>
  <div id="f03-verdict" class="clip f03-verdict" data-start="0" data-duration="15.11">
    <div class="f03-vline">Over the term, <b>fixed</b> cost less interest by</div>
    <div class="f03-num" id="f03-num">$0</div>
  </div>
</div>

And the motion, which is ordinary GSAP with one framework rule: the timeline is registered on window.__timelines under the composition’s id, and it is created paused, because the renderer — not the clock — decides what time it is:

window.__timelines = window.__timelines || {};
(function () {
  var num = document.getElementById("f03-num"), counter = { v: 0 };
  gsap.set([date, lede], { opacity: 0, y: 20 });          // hidden state set OUTSIDE the timeline
  var tl = gsap.timeline({ paused: true });
  tl.to(date, { opacity: 1, y: 0, duration: 0.5 }, 0.3);
  tl.to(c1,   { opacity: 1, y: 0, duration: 0.6 }, 2.4);
  tl.to(c2,   { opacity: 1, y: 0, duration: 0.6 }, 3.2);
  tl.to(counter, { v: 48935, duration: 1.9, ease: "power2.out",
    onUpdate: function () { num.textContent = "$" + Math.round(counter.v).toLocaleString("en-CA"); }
  }, 8.7);
  tl.to({}, { duration: 15.11 }, 0);                       // pin the timeline's length to the scene's
  window.__timelines["03-2021"] = tl;
})();

Read the 48935. It is not typed into the video; it is the figure the article’s mortgage simulator produced, and the video’s README says so for every number on screen: the rate chart in scene two is a polyline generated from the same rates.json the article uses, and the bar widths in scene six are proportional to the real interest margins. That discipline is not HyperFrames’. It is the only reason a video about numbers can be trusted at all, and HTML makes it cheap, because a value in a file is a value a script can write.

Why “seek” is the word that matters

HyperFrames renders by asking the composition what it looks like at an exact time, capturing that, and moving on. Its docs put it plainly: “For the same source, media, and settings, an exact timestamp should resolve to the same frame.” A render worker on a cold start can therefore jump to second forty-one without ever having shown seconds zero to forty. Everything that follows from that is in the framework’s rules page, and most of it is about things that look fine in a preview — which plays sequentially — and break in a file:

  • Reveal the destination, not just the source. An element hidden and revealed with gsap.fromTo() needs opacity: 1 in the to vars. A cold worker restores the authored hidden state; without the destination it stays invisible. (gsap_cold_seek_hidden_fromto_missing_reveal)
  • Set the initial hidden state outside the timeline. A tl.set() at position zero may not have applied when frame zero renders. Scene three above does it right: a bare gsap.set() before the timeline exists. (gsap_timeline_set_initial_hide)
  • No relative tweens on a property something else animates. "+=50" captures its base when the tween initialises, and a cold worker initialises it from a different state than sequential playback did.
  • Never measure the DOM inside a timeline callback. getBoundingClientRect() depends on whatever state the render happens to be in, and callbacks re-fire on every seek. Measure once, outside.
  • SVG draw-on has three traps of its own: a CSS stroke-dasharray that GSAP merges with the animated one; a stroke-linecap: round that paints a dot at zero length; a path measured before its d exists.
  • No Date.now(), no Math.random(), no fetches. Determinism is not a preference; it is what makes the frame at 41.64 s the same frame on every worker.

The linter carries a code for eight of these, and check runs the linter and then a browser pass: runtime, layout (a text block covered by another element is text_occluded), motion, and WCAG AA contrast on every text element. On the finance project, 26 of 26 pass; the whole check took sixteen seconds. It is the cheapest step in the pipeline and the one that turns “it played in the preview” into “it will render.”

The narrator

Kokoro is an open-weight text-to-speech model of 82 million parameters — small enough that its model card quotes the API market rate for scale, under a dollar per million characters, and then notes that with Apache-licensed weights you can also just run it. Version 1.0 shipped in January 2025 with 54 voices across 8 languages; every Collegica video uses af_heart, and every project’s README records the same first attempt: macOS’s built-in say -v Samantha, rejected as robotic before the first video was finished.

HyperFrames wraps it as hyperframes tts, which on this machine runs through kokoro-onnx — the model on ONNX Runtime, no PyTorch — in a Python environment the project’s README tells you to create:

python3 -m venv .venv && source .venv/bin/activate
pip install kokoro-onnx soundfile
npx --yes [email protected] tts "Your line here." -v af_heart -o audio/frame-03.wav

Measured: two sentences, thirty words, 8.6 seconds of speech in 9.7 seconds of wall time — and that included downloading the voice file, because it was not yet cached. What it keeps in ~/.cache/hyperframes/tts/ is a 324 MB model and a 28 MB voice file; the “~27 MB” the READMEs mention is the voices, not the model. The output is 24 kHz mono WAV.

Two things the READMEs teach that no documentation does. First, the narration is measured, and the picture is fitted to it: generate the WAV, read its duration with ffprobe, and scale that scene’s cue timings and data-duration to match — never the other way round, because a spoken line takes as long as it takes. Second, the model reads “Collegica” wrong, and the fix is to spell it College-ika in the text handed to tts while the on-screen text keeps the real spelling. Every narrated project has a word like that; find it in the first scene, not the last.

Rendering, with a stopwatch

npx --yes [email protected] check
npx --yes [email protected] render -o _output/fixed-or-variable.mp4 --crf 23 --low-memory-mode

On an M4 with 24 GB, with a browser and an editor open: 3,013 frames in 2 minutes 10 seconds, one worker, screenshot capture; 4.3 MB of H.264 at 1920 × 1080 and 30 fps, AAC audio, 100.43 seconds. A hundred-second video renders in about the time it takes to watch it.

Two things about that command are worth knowing before you copy it.

--low-memory-mode is not what the project’s README says it is. The README calls it a streaming mode that avoided 25 GB of temporary PNGs; the CLI’s own help calls it a safe profile — one worker, screenshot capture, no worker calibration, “to avoid memory thrash on constrained machines,” and on by default on 8 GB or less. The disk overrun that session hit was real; the documented remedy for a small temp partition is --frames-cache-dir, which relocates the frame cache. The flag helped, for a reason adjacent to the one written down. Read render --help; it is where the meaning lives.

A re-render is not the same file. The shipped MP4 is 4,554,529 bytes; the one rendered for this guide is 4,554,482 — 47 bytes apart, a different hash. Nothing in the frames differs; H.264 encoders simply do not promise identical bytes across runs. HyperFrames’ determinism is about the frame at a timestamp, which is the promise you need. Do not put an MP4 under a byte-for-byte test. Compare durations, or frames, or watch it.

What this cost

Nothing, after the hardware. Both tools are Apache-2.0; the model is on your disk; the render is your CPU. The finance video’s README has the full cost line in six words — renders locally for free — and each of the four projects is 500 to 600 lines of HTML — the root and seven scenes — plus the narration scripts, plus a README that says what is peculiar to it. The agent that wrote them loaded HyperFrames’ own skills first (npx hyperframes skills update; the /hyperframes router), which is the framework’s recommended path and, on the evidence of four videos, the right one: the rules above are not in generic web knowledge, and an agent that has not read them will write a fromTo that vanishes on a cold seek.

What to do this week

  • Pick an article you have already written, and write seven sentences that summarise it. That is the script, and the script sets the length.
  • npx hyperframes init, install the skills, and ask for a faceless explainer from those sentences. Read the composition it writes before you render it.
  • Put your numbers in a file and generate the markup from it. A figure typed into a video is a figure nobody can check.
  • Run check after every edit. Sixteen seconds.
  • Render with the stopwatch running, then re-render and compare durations, not bytes.
  • Find the word your narrator mispronounces, and respell it in the script only.

Sources

Research notes — what was read, what was measured, and the correction on --low-memory-mode — are in the accompanying folder. The four video projects are in video/.

  • HyperFrames. heygen-com/hyperframes (Apache-2.0); the docs pages How a HyperFrames project works, Rules and anti-patterns, Finish and share a video, Use voice, music, sound, and captions; [email protected] render --help.
  • Kokoro. hexgrad/kokoro (Apache-2.0); the Kokoro-82M model card; kokoro-onnx.
  • The videos. Fixed or Variable (100 s, 2026-09-11), Planning to Age Well, with AI (90 s, 2026-09-09), Finding Your Next Job, with AI (90 s, 2026-09-10), A Year of Spending, from Your Own Statements (90 s, 2026-09-10) — sources and READMEs under video/.

© 2026 Collegica

A learning companion to Mechatronics3D

  • About

  • Events

  • Talks

  • For AI agents