My landing video is an HTML file, claude became something else with hyperframes!
I wanted a short loop for the top of zakaria.lu: something that shows the parts of the site (the portfolio, these field notes, BuildCV, the experience) without making a visitor scroll. I don't do motion design, and I wasn't going to learn After Effects for 25 seconds of video. So I tried HyperFrames with Claude Code doing the work, and what came out is a video whose source is plain HTML.
What HyperFrames actually is
HyperFrames is open source (Apache-2.0); its repo sums it up as "Write HTML. Render video. Built for agents." I used version 0.8.225. It renders an HTML page to video by seeking a paused GSAP timeline one frame at a time and capturing each frame. That one rule shapes everything: nothing on screen may depend on the wall clock, on an unseeded Math.random(), on timers or on the network. Every pixel is a function of timeline time, so every render is identical.
As a backend engineer, I know this kind of contract. A frame is a pure function of t. If a check fails, it fails the same way every time.
The brief and the storyline
The agent started by capturing the live site: its colours, fonts, a screenshot of each section, and my headshot. Next it studied a library of "Moments", which are passages of real launch films rebuilt as code. Then it pitched three storylines.
I picked the one called "Proof, still running". It opens on the site's own uptime clock, shows the single Scala/Play container that serves everything, walks through the sections and ends on the contact button. There's no sound, because it will loop muted on a landing page.

hyperframes snapshot. Everything on screen is the site's real UI, re-laid out at 1920x1080.Scenes are HTML files
The film is seven scenes, each in its own file under compositions/. The root index.html holds no content at all; it only says when each scene starts and how long it lasts:
<div id="work" data-composition-id="work"
data-composition-src="compositions/work.html"
data-start="11.6" data-duration="3.4"></div>
Inside a scene, the markup is normal HTML and CSS. The motion is one paused GSAP timeline, registered under the scene's id:
const tl = gsap.timeline({ paused: true });
// the page scrolls up into place from the hero's whip
tl.fromTo("#wk-page", { y: 420 }, { y: 0, duration: 0.42, ease: "power3.out" }, 0);
// BuildCV is picked out of the project list
tl.fromTo("#wk-hi", { scaleX: 0 }, { scaleX: 1, duration: 0.35, ease: "power3.out" }, 1.85);
window.__timelines["work"] = tl;

A nice consequence is that the copy, the colours and the screenshots are just DOM. Changing "Ideas, made real" is a text edit, not a re-export.
HyperFrames Studio edits the same files. I can nudge a scene by hand, and the agent sees my change on its next turn.
Porting motion instead of inventing it
The agent didn't improvise the camera work. Two scenes are ported from Moments:
- The opening zoom-out, fly-ins, wires and typed caption come from M1296.
- The card stack near the end comes from M1599.
"Ported" means the timeline code was copied with its timings and eases kept. Only the subject was swapped for mine: my uptime panel instead of their product, my site sections instead of their cards.

The gate that argued about one pixel
This is the part I found most interesting. Before the agent may call the film done, it has to pass two checks:
hyperframes check --jsonlints the composition against the render rules.- A film gate renders all 750 frames (25 s at 30 fps) and measures them. It checks for empty frames, dead time and jitter, that the brand mark is present, that nothing creeps after it lands, and that every scene cut happens while both sides are still moving.
The linter's complaint was easy. The stack cards overlap on purpose, so they got data-layout-allow-overlap.
The film gate was harder. It failed the stack scene for jitter:
FAIL no jitter 3 element(s) wobble
<box> #st-c4 [stack]: y wobbles 2x in 0.10s (legs 224, 1, 1 px)
That's a card travelling 224 px and then moving back by 1 px. Nobody would see it. But the gate tracks every element's box frame by frame, and a change of direction is a change of direction. The fix took three rounds:
- The ported ease was
bez(0, 1.2, 0, 1), a 20% overshoot. At 30 fps that overshoot shows up as a 1 px backtrack, so it becamebez(0, 1, 0, 1). - The scene zooms the whole page out while each card moves. I folded that zoom into each card's own track (
x' = fx + fs·x,s' = fs·s), so each card had one transform instead of two fighting each other. - That still failed, because each card's centre hovered within a pixel of y 540 while it scaled. The final fix zooms each card about one computed fixed point, so every point on the card travels in one direction only.

const r = s1 / s0; // scale before -> after the zoom-out
const C = (p1 - p0 * r) / (1 - r); // the point that does not move
const pos = (scale) => C + (p0 - C) * (scale / s0);
The same pass fixed a few smaller things:
- A blur on the final fall-away had to go. As the blur spread a card's edge, the gate read it as the card moving upwards.
- Some scenes had an empty first frame. The work page, for example, now starts 420 px low instead of invisible.
- Ticking clock digits overlapped for a frame.
- The "10+" counter got
tabular-numsand a fixed width, so it no longer shifts the layout as it counts.
I like this a lot. It's the same discipline as hunting a flaky test. The symptom was 1 px, but the cause was two transforms disagreeing about where a card was, and the gate wouldn't accept "looks fine to me".
What I'd change
- The clock isn't live. The film opens on the site's uptime clock, but in a video the numbers are a snapshot from the capture, while the real one keeps running. If the loop sits next to the real clock, I'd rather put that panel over the video as live HTML than pretend.
- Regenerate on deploy. Because the source is HTML, the release hash and post count could be variables filled in at build time, with CI re-rendering the video. I haven't built that yet; it's the obvious next step.
- Embedding. I still have to export it and embed it as a muted, looping
<video>with a poster frame. I'll check the file size before it goes anywhere near the landing page.
What stays with me is how unglamorous the good parts were. The agent wrote HTML, a gate measured frames, and most of the effort went into making motion agree with itself to the pixel. That's work I recognise.