`, a custom element, or a `
` you draw into yourself. Tracks may overlap in time; layering uses source order plus `z-index`.
## Try the dimensions
`}
knobs={[
{ name: "W", type: "number", default: "1280", min: 320, max: 1920 },
{ name: "H", type: "number", default: "720", min: 240, max: 1080 },
{ name: "DUR", type: "number", default: "1.5", min: 0.2, max: 5 }
]}
/>
## Why not React?
Because every model, every templating engine, and every CMS already speaks HTML. A composition you can write in three lines of Vim is a composition an LLM can generate in one prompt, a templating system can serve from a server response, and a junior dev can read at 2am. React adds a build step, a runtime, and a hydration model that the renderer has to either reimplement or ignore. We chose ignore.
That said — if you like JSX, render to HTML and feed *that* to HyperFrames. The renderer is happy with whatever HTML you can produce.
## Next
- [Data attributes](/docs/concepts/data-attributes) — the full reference table
- [Timing & tracks](/docs/concepts/timing-and-tracks) — how the seek clock works
---
# Data attributes
URL: https://hyperframes.video/docs/concepts/data-attributes
Description: Every HyperFrames data-* attribute, what it does, and what it looks like in isolation. The quick-jump reference.
HyperFrames reads timing and layout from HTML `data-*` attributes. They are inert in a normal browser and stripped from the captured DOM, so they never leak into the rendered pixels.
## What you'll learn
- The full attribute table for roots, tracks, and media
- What `data-fade` and `data-loop` look like on their own
- Where to find the JSON schema that backs all of this
## Composition root
| Attribute | Required | Default | Description |
|---|---|---|---|
| `data-width` | yes | — | Output width in pixels. |
| `data-height` | yes | — | Output height in pixels. |
| `data-duration` | no | inferred | Total duration in seconds. If omitted, derived from longest child track. |
| `data-fps` | no | `60` | Output frame rate. |
| `data-bg` | no | `#000` | Background color when transparent encoding is off. |
## Tracks
| Attribute | Description |
|---|---|
| `data-start` | Seconds from composition start. |
| `data-duration` | How long this track is on stage. |
| `data-end` | Alternative to `data-duration`. |
| `data-loop` | If present, the track loops within its window. |
| `data-fade` | `"in"`, `"out"`, or `"both"` — applies a 200 ms fade. |
| `data-track` | Optional name for the inspector. |
## Media
| Attribute | Applies to | Description |
|---|---|---|
| `data-volume` | ``, `` | 0–1 mix gain. |
| `data-mute` | ``, `` | Strips audio from this track. |
| `data-trim-start` | ``, `` | Seconds to skip into the source file. |
| `data-speed` | ``, `` | Playback rate (also re-pitches audio). |
## `data-fade` in isolation
data-fade="both"
`}
/>
## `data-loop` in isolation
`}
/>
## Combined example
```html
```
## Validation
Every attribute is type-checked by the linter. `hyperframes lint comp.html --json` returns a structured list of attribute errors, including the exact byte offset — perfect for agent self-correction loops.
## Next
- [Composition](/docs/concepts/composition) — the mental model these attributes hang off of
- [Schema reference](/docs/reference/schema) — the machine-readable spec
---
# Timing & tracks
URL: https://hyperframes.video/docs/concepts/timing-and-tracks
Description: How HyperFrames advances time with a virtual seek clock, dispatches hf-seek per frame, and drives multiple tracks in lockstep.
HyperFrames does not run a real-time clock. It runs a *seek loop* — for each frame, it sets the page's notion of time to `frame_index / fps`, lets layout settle, and captures the pixel buffer. Everything else, from CSS animations to GSAP timelines, hangs off that one event.
## What you'll learn
- Why `setTimeout` and `requestAnimationFrame` are stubbed at render time
- How the `hf-seek` event drives JavaScript animations deterministically
- How multiple tracks stay in lockstep across a single scrub
## The runtime clock
When HyperFrames renders, time is a function, not a thread. For every output frame:
1. The clock advances to `frame_index / fps`.
2. `setTimeout`, `setInterval`, `requestAnimationFrame`, `Date.now()`, and `performance.now()` are reseeded.
3. A `hf-seek` `CustomEvent` is dispatched on `window`.
4. Styles and layout flush.
5. Pixels are captured.
The whole pipeline is synchronous from the page's point of view. There is no race between your animation tick and the capture.
## The `hf-seek` event
```js
window.addEventListener("hf-seek", (e) => {
// e.detail.time — seconds into the composition
// e.detail.frame — frame index
// e.detail.duration — total seconds
});
```
This is the canonical hook for any motion you can't express in pure CSS.
## Four tracks, one scrubber
The demo below has four tracks — a progress bar, a number counter, a moving dot, and a rotating square. All four read from the same `hf-seek` event, so dragging the scrubber moves them as a unit. That's the whole timing model in one screen.
`}
/>
## CSS animations
Pure CSS animations work out of the box. The renderer samples `animation-play-state`, `animation-delay`, and `animation-duration` per frame. Avoid `animation-timing-function: steps()` with very small step counts — the rounding can land between frames.
## Third-party libraries
Most popular animation libraries have adapters in `@hyperframes/adapters`:
- **GSAP** — `gsap.ticker` is pinned to HF time
- **Lottie / dotLottie** — `goToAndStop(frame)` is driven by `e.detail.frame`
- **Three.js** — `AnimationMixer` and a `Clock` proxy
- **Rive** — `advance(delta)` is called per frame
- **Anime.js**, **WAAPI**, **D3** — covered too
See [Frame adapters](/docs/concepts/frame-adapters) for the full table.
## Next
- [Frame adapters](/docs/concepts/frame-adapters) — pin your animation library to the seek clock
- [Preview](/docs/workflow/preview) — scrub, frame-step, and reload while you author
---
# Frame adapters
URL: https://hyperframes.video/docs/concepts/frame-adapters
Description: Thin shims that pin third-party animation libraries to the HyperFrames seek clock. GSAP, Lottie, WAAPI, Three.js, and your own engine.
A frame adapter is the five-line bridge between an animation library and the HyperFrames seek clock. Most adapters are auto-loaded — they detect the library at runtime, replace its real-time tick with an HF-driven one, and otherwise stay out of your way.
## What you'll learn
- The 5-line pattern every adapter follows
- Drop-in adapters for the libraries you probably already use
- How to write a custom adapter for a non-seekable engine
## Built-in adapters
| Library | Adapter | What it does |
|---|---|---|
| GSAP | `@hyperframes/adapters/gsap` | Replaces `gsap.ticker` with `hf-seek`. |
| Lottie | `@hyperframes/adapters/lottie` | `goToAndStop(frame)` per HF frame. |
| Three.js | `@hyperframes/adapters/three` | Pins `AnimationMixer` + `Clock`. |
| Rive | `@hyperframes/adapters/rive` | Calls `advance(delta)`. |
| WAAPI | `@hyperframes/adapters/waapi` | Sets `currentTime` on `Element.animate()` outputs. |
| D3 | `@hyperframes/adapters/d3` | Shims `d3-timer` to HF ticks. |
| PixiJS | `@hyperframes/adapters/pixi` | Proxies `Ticker.system`. |
## The pattern, by library
Every adapter does the same thing: subscribe to `hf-seek`, push the time into the engine. Tabs below show the exact shape for the libraries you're most likely to use.
{
gsap.ticker.tick(e.detail.time);
});` },
{ label: "Lottie", lang: "js", code: `import lottie from "lottie-web";
const anim = lottie.loadAnimation({ container, path, autoplay: false });
addEventListener("hf-seek", (e) => {
anim.goToAndStop(e.detail.frame, true);
});` },
{ label: "WAAPI", lang: "js", code: `const a = el.animate(keyframes, { duration: 4000, fill: "both" });
a.pause();
addEventListener("hf-seek", (e) => {
a.currentTime = e.detail.time * 1000;
});` },
{ label: "Three.js", lang: "js", code: `import * as THREE from "three";
const mixer = new THREE.AnimationMixer(model);
const action = mixer.clipAction(clip).play();
let last = 0;
addEventListener("hf-seek", (e) => {
mixer.update(e.detail.time - last);
last = e.detail.time;
renderer.render(scene, camera);
});` },
{ label: "Custom", lang: "js", code: `// Your own engine. Five lines.
window.__hf = window.__hf || {};
addEventListener("hf-seek", (e) => {
myEngine.setTime(e.detail.time);
myEngine.draw();
});` }
]}
/>
## Non-seekable engines
Impulse physics, particle systems, and other state-accumulating engines can't simply jump to time `t`. They have to *replay* deterministically from frame 0 each seek, or pre-bake the motion into a sampled buffer.
The common trick is to detect a backwards seek and reset the engine, then advance forward to the current time:
```js
let last = 0;
addEventListener("hf-seek", (e) => {
if (e.detail.time < last) engine.reset();
while (last < e.detail.time) {
engine.step(1 / 60);
last += 1 / 60;
}
});
```
This is slower than a true seekable engine but still deterministic — the same time always yields the same state.
## Loading adapters
Adapters are tree-shakable. Import only the ones you use:
```js
import "@hyperframes/adapters/gsap";
import "@hyperframes/adapters/lottie";
```
Or let the auto-loader handle it by adding `` to your composition — it sniffs the page and loads matching adapters on demand.
## Next
- [Timing & tracks](/docs/concepts/timing-and-tracks) — what `hf-seek` is and how it fires
- [AI agents](/docs/recipes/ai-agents) — let a model pick the adapter for you
---
# Deterministic rendering
URL: https://hyperframes.video/docs/concepts/deterministic-rendering
Description: Why two HyperFrames renders of the same composition produce byte-identical MP4 files — the seek loop, stubbed clocks, pinned fonts, and frozen encoder flags.
Determinism is the promise that `sha256sum out.mp4` returns the same hash on your laptop, on CI, and on a colleague's machine three months from now. HyperFrames earns that promise by removing every source of nondeterminism from the render path.
## What you'll learn
- Why the renderer drives time instead of the browser
- Which browser APIs are stubbed and what replaces them
- How fonts, encoder flags, and the frame manifest are pinned
- Why this matters for CI, diffing, and audit
## The seek loop replaces the wall clock
A normal browser animation is driven by `requestAnimationFrame`. That ties output to whatever the OS scheduler decided was 16.7ms — which it almost never was. HyperFrames does not let the browser drive time at all.
Instead, the renderer holds the clock. For each frame in `[0, duration)` at the chosen frame rate, it sets the page time, dispatches a `hf-seek` event with the new time, waits for layout to settle, and screenshots. The browser is a pure projection of `time → pixels`.
## Stubbed clocks and randomness
Before any user code runs, the renderer installs replacements for everything that could leak entropy into a frame:
- `Date.now()`, `performance.now()`, and `new Date()` return the current seek time
- `Math.random()` is seeded from the composition hash, not from system entropy
- `requestAnimationFrame` resolves synchronously against the seek clock
- `setTimeout` and `setInterval` are queued against seek time, not real time
- `crypto.getRandomValues` uses the same seeded PRNG
If your composition reads any of these, it reads a value that is a pure function of the frame being rendered.
## Sandboxed font fallback
Fonts are the single biggest cause of cross-machine drift. The same CSS on two laptops can resolve to two different fonts because one machine has Helvetica installed and the other has Arial.
HyperFrames ships a frozen font sandbox. The browser sees exactly the fonts declared in the composition's ` ` and `@font-face` rules, plus a single deterministic fallback (a bundled metric-compatible substitute). System fonts are unreachable. If a glyph is missing, you see the box, not whatever the host OS happened to have.
## Pinned FFmpeg encoder flags
The screenshot pipeline emits PNGs. FFmpeg muxes them into MP4. The encoder is invoked with a frozen flag set — same libx264 version, same preset, same `-pix_fmt yuv420p`, same `-movflags +faststart`, same color primaries. Bumping HyperFrames is the only way those flags change, and every bump notes which hashes shift.
## Frame manifest with content hashes
Every render writes a sidecar `manifest.json`:
```json
{
"composition_sha": "9e3c…",
"renderer_version": "1.4.2",
"ffmpeg_args_sha": "a081…",
"frames": [
{ "n": 0, "t": 0.0, "sha": "f1c2…" },
{ "n": 1, "t": 0.0333, "sha": "27ab…" }
],
"output_sha": "b4d0…"
}
```
Two manifests with matching `composition_sha` and matching `renderer_version` must have matching `output_sha`. If they do not, the renderer has a bug — and the per-frame hashes tell you exactly which frame diverged.
## Verifying it yourself
The hashes match because nothing in the path between HTML and MP4 was allowed to vary.
## What determinism does not give you
It does not give you perceptual stability across renderer upgrades. A new libx264 release can shift bytes without shifting pixels. The manifest records `renderer_version` for exactly this reason: a hash change without a version change is a bug; a hash change with one is expected and gated.
It also does not protect you from your own nondeterminism. If you fetch from a live API inside the composition, the render is only as deterministic as the API. Pin your data the same way HyperFrames pins everything else.
## Next
- [Timing & tracks](/docs/concepts/timing-and-tracks)
- [Render workflow](/docs/workflow/render)
---
# Variables & templating
URL: https://hyperframes.video/docs/concepts/variables-and-templating
Description: The {{$VAR}} token model and JSON variants manifest in HyperFrames — define variables, set defaults, and render a personalized video per row of data.
A HyperFrames composition is a template the moment it contains a `{{$NAME}}` token. The renderer substitutes those tokens before the page boots, so the same composition produces one video, a thousand videos, or a video per row of a CSV without any change to the markup.
## What you'll learn
- The `{{$VAR}}` token model and where substitution happens
- How to declare defaults so a composition stays previewable
- The CLI surface: `--vars` for one render, `--variants` for a manifest
- How to escape literal `{{` if you really need it
## The token model
Anywhere in the HTML — attribute values, text content, inline `
Clip A
Clip B