diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
index 6850741f..c43fe280 100644
--- a/.agents/plugins/marketplace.json
+++ b/.agents/plugins/marketplace.json
@@ -1381,6 +1381,18 @@
"authentication": "ON_INSTALL"
},
"category": "Coding"
+ },
+ {
+ "name": "hyperframes",
+ "source": {
+ "source": "local",
+ "path": "./plugins/hyperframes"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Design"
}
]
}
diff --git a/plugins/hyperframes/.codex-plugin/plugin.json b/plugins/hyperframes/.codex-plugin/plugin.json
new file mode 100644
index 00000000..183f278f
--- /dev/null
+++ b/plugins/hyperframes/.codex-plugin/plugin.json
@@ -0,0 +1,43 @@
+{
+ "name": "hyperframes",
+ "version": "0.1.0",
+ "description": "Write HTML, render video. Compositions, GSAP animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.",
+ "author": {
+ "name": "HeyGen",
+ "email": "hyperframes@heygen.com",
+ "url": "https://hyperframes.heygen.com"
+ },
+ "homepage": "https://hyperframes.heygen.com",
+ "repository": "https://github.com/heygen-com/hyperframes",
+ "license": "Apache-2.0",
+ "keywords": [
+ "hyperframes",
+ "video",
+ "html",
+ "gsap",
+ "animation",
+ "composition",
+ "rendering",
+ "captions",
+ "tts",
+ "audio-reactive"
+ ],
+ "skills": "./skills/",
+ "interface": {
+ "displayName": "HyperFrames",
+ "shortDescription": "Write HTML, render video",
+ "longDescription": "Build videos from HTML with HyperFrames. Author compositions with HTML + CSS + GSAP, use the CLI for init/preview/render/transcribe/tts, install reusable registry blocks and components, follow the GSAP animation reference, and turn any website into a video with the 7-step capture-to-video pipeline.",
+ "developerName": "HeyGen",
+ "category": "Design",
+ "capabilities": ["Read", "Write"],
+ "websiteURL": "https://hyperframes.heygen.com",
+ "defaultPrompt": [
+ "Turn this website into a 20-second product promo",
+ "Create an animated title card with kinetic type",
+ "Add synced captions to this voiceover"
+ ],
+ "brandColor": "#0a0a0a",
+ "composerIcon": "./assets/icon.png",
+ "logo": "./assets/logo.png"
+ }
+}
diff --git a/plugins/hyperframes/README.md b/plugins/hyperframes/README.md
new file mode 100644
index 00000000..a210b746
--- /dev/null
+++ b/plugins/hyperframes/README.md
@@ -0,0 +1,26 @@
+# hyperframes
+
+OpenAI Codex plugin for [HyperFrames](https://hyperframes.heygen.com) — an open-source video rendering framework where HTML is the source of truth for video.
+
+## What's included
+
+Five skills for authoring and rendering video:
+
+- **hyperframes** — composition authoring (HTML + CSS + GSAP), visual styles, palettes, house style, motion principles, transitions, captions, audio-reactive visuals
+- **hyperframes-cli** — `hyperframes init / lint / preview / render / transcribe / tts / doctor / browser`
+- **hyperframes-registry** — `hyperframes add` to install reusable blocks and components (social overlays, shader transitions, data viz, effects)
+- **gsap** — tweens, timelines, easing, stagger, performance
+- **website-to-hyperframes** — 7-step pipeline that captures a URL and produces a finished video
+
+## Requirements
+
+The skills invoke the `hyperframes` CLI via `npx hyperframes`, which needs:
+
+- Node.js ≥ 22
+- FFmpeg on `PATH`
+
+See [hyperframes.heygen.com/quickstart](https://hyperframes.heygen.com/quickstart) for full setup.
+
+## Source of truth
+
+The skills are authored in [`heygen-com/hyperframes`](https://github.com/heygen-com/hyperframes) (under `skills/` at the repo root) and mirrored here. File issues about skill content on that repo.
diff --git a/plugins/hyperframes/assets/icon.png b/plugins/hyperframes/assets/icon.png
new file mode 100644
index 00000000..05a8356a
Binary files /dev/null and b/plugins/hyperframes/assets/icon.png differ
diff --git a/plugins/hyperframes/assets/logo.png b/plugins/hyperframes/assets/logo.png
new file mode 100644
index 00000000..3dceea06
Binary files /dev/null and b/plugins/hyperframes/assets/logo.png differ
diff --git a/plugins/hyperframes/skills/gsap/SKILL.md b/plugins/hyperframes/skills/gsap/SKILL.md
new file mode 100644
index 00000000..84fbdacb
--- /dev/null
+++ b/plugins/hyperframes/skills/gsap/SKILL.md
@@ -0,0 +1,211 @@
+---
+name: gsap
+description: GSAP animation reference for HyperFrames. Covers gsap.to(), from(), fromTo(), easing, stagger, defaults, timelines (gsap.timeline(), position parameter, labels, nesting, playback), and performance (transforms, will-change, quickTo). Use when writing GSAP animations in HyperFrames compositions.
+---
+
+# GSAP
+
+## Core Tween Methods
+
+- **gsap.to(targets, vars)** — animate from current state to `vars`. Most common.
+- **gsap.from(targets, vars)** — animate from `vars` to current state (entrances).
+- **gsap.fromTo(targets, fromVars, toVars)** — explicit start and end.
+- **gsap.set(targets, vars)** — apply immediately (duration 0).
+
+Always use **camelCase** property names (e.g. `backgroundColor`, `rotationX`).
+
+## Common vars
+
+- **duration** — seconds (default 0.5).
+- **delay** — seconds before start.
+- **ease** — `"power1.out"` (default), `"power3.inOut"`, `"back.out(1.7)"`, `"elastic.out(1, 0.3)"`, `"none"`.
+- **stagger** — number `0.1` or object: `{ amount: 0.3, from: "center" }`, `{ each: 0.1, from: "random" }`.
+- **overwrite** — `false` (default), `true`, or `"auto"`.
+- **repeat** — number or `-1` for infinite. **yoyo** — alternates direction with repeat.
+- **onComplete**, **onStart**, **onUpdate** — callbacks.
+- **immediateRender** — default `true` for from()/fromTo(). Set `false` on later tweens targeting the same property+element to avoid overwrite.
+
+## Transforms and CSS
+
+Prefer GSAP's **transform aliases** over raw `transform` string:
+
+| GSAP property | Equivalent |
+| --------------------------- | ------------------- |
+| `x`, `y`, `z` | translateX/Y/Z (px) |
+| `xPercent`, `yPercent` | translateX/Y in % |
+| `scale`, `scaleX`, `scaleY` | scale |
+| `rotation` | rotate (deg) |
+| `rotationX`, `rotationY` | 3D rotate |
+| `skewX`, `skewY` | skew |
+| `transformOrigin` | transform-origin |
+
+- **autoAlpha** — prefer over `opacity`. At 0: also sets `visibility: hidden`.
+- **CSS variables** — `"--hue": 180`.
+- **svgOrigin** _(SVG only)_ — global SVG coordinate space origin. Don't combine with `transformOrigin`.
+- **Directional rotation** — `"360_cw"`, `"-170_short"`, `"90_ccw"`.
+- **clearProps** — `"all"` or comma-separated; removes inline styles on complete.
+- **Relative values** — `"+=20"`, `"-=10"`, `"*=2"`.
+
+## Function-Based Values
+
+```javascript
+gsap.to(".item", {
+ x: (i, target, targets) => i * 50,
+ stagger: 0.1,
+});
+```
+
+## Easing
+
+Built-in eases: `power1`–`power4`, `back`, `bounce`, `circ`, `elastic`, `expo`, `sine`. Each has `.in`, `.out`, `.inOut`.
+
+## Defaults
+
+```javascript
+gsap.defaults({ duration: 0.6, ease: "power2.out" });
+```
+
+## Controlling Tweens
+
+```javascript
+const tween = gsap.to(".box", { x: 100 });
+tween.pause();
+tween.play();
+tween.reverse();
+tween.kill();
+tween.progress(0.5);
+tween.time(0.2);
+```
+
+## gsap.matchMedia() (Responsive + Accessibility)
+
+Runs setup only when a media query matches; auto-reverts when it stops matching.
+
+```javascript
+let mm = gsap.matchMedia();
+mm.add(
+ {
+ isDesktop: "(min-width: 800px)",
+ reduceMotion: "(prefers-reduced-motion: reduce)",
+ },
+ (context) => {
+ const { isDesktop, reduceMotion } = context.conditions;
+ gsap.to(".box", {
+ rotation: isDesktop ? 360 : 180,
+ duration: reduceMotion ? 0 : 2,
+ });
+ },
+);
+```
+
+---
+
+## Timelines
+
+### Creating a Timeline
+
+```javascript
+const tl = gsap.timeline({ defaults: { duration: 0.5, ease: "power2.out" } });
+tl.to(".a", { x: 100 }).to(".b", { y: 50 }).to(".c", { opacity: 0 });
+```
+
+### Position Parameter
+
+Third argument controls placement:
+
+- **Absolute**: `1` — at 1s
+- **Relative**: `"+=0.5"` — after end; `"-=0.2"` — before end
+- **Label**: `"intro"`, `"intro+=0.3"`
+- **Alignment**: `"<"` — same start as previous; `">"` — after previous ends; `"<0.2"` — 0.2s after previous starts
+
+```javascript
+tl.to(".a", { x: 100 }, 0);
+tl.to(".b", { y: 50 }, "<"); // same start as .a
+tl.to(".c", { opacity: 0 }, "<0.2"); // 0.2s after .b starts
+```
+
+### Labels
+
+```javascript
+tl.addLabel("intro", 0);
+tl.to(".a", { x: 100 }, "intro");
+tl.addLabel("outro", "+=0.5");
+tl.play("outro");
+tl.tweenFromTo("intro", "outro");
+```
+
+### Timeline Options
+
+- **paused: true** — create paused; call `.play()` to start.
+- **repeat**, **yoyo** — apply to whole timeline.
+- **defaults** — vars merged into every child tween.
+
+### Nesting Timelines
+
+```javascript
+const master = gsap.timeline();
+const child = gsap.timeline();
+child.to(".a", { x: 100 }).to(".b", { y: 50 });
+master.add(child, 0);
+```
+
+### Playback Control
+
+`tl.play()`, `tl.pause()`, `tl.reverse()`, `tl.restart()`, `tl.time(2)`, `tl.progress(0.5)`, `tl.kill()`.
+
+---
+
+## Performance
+
+### Prefer Transform and Opacity
+
+Animating `x`, `y`, `scale`, `rotation`, `opacity` stays on the compositor. Avoid `width`, `height`, `top`, `left` when transforms achieve the same effect.
+
+### will-change
+
+```css
+will-change: transform;
+```
+
+Only on elements that actually animate.
+
+### gsap.quickTo() for Frequent Updates
+
+```javascript
+let xTo = gsap.quickTo("#id", "x", { duration: 0.4, ease: "power3" }),
+ yTo = gsap.quickTo("#id", "y", { duration: 0.4, ease: "power3" });
+container.addEventListener("mousemove", (e) => {
+ xTo(e.pageX);
+ yTo(e.pageY);
+});
+```
+
+### Stagger > Many Tweens
+
+Use `stagger` instead of separate tweens with manual delays.
+
+### Cleanup
+
+Pause or kill off-screen animations.
+
+---
+
+## References (loaded on demand)
+
+- **[references/effects.md](references/effects.md)** — Drop-in effects: typewriter text, audio visualizer. Read when needing ready-made effect patterns for HyperFrames.
+
+## Best Practices
+
+- Use camelCase property names; prefer transform aliases and autoAlpha.
+- Prefer timelines over chaining with delay; use the position parameter.
+- Add labels with `addLabel()` for readable sequencing.
+- Pass defaults into timeline constructor.
+- Store tween/timeline return value when controlling playback.
+
+## Do Not
+
+- Animate layout properties (width/height/top/left) when transforms suffice.
+- Use both svgOrigin and transformOrigin on the same SVG element.
+- Chain animations with delay when a timeline can sequence them.
+- Create tweens before the DOM exists.
+- Skip cleanup — always kill tweens when no longer needed.
diff --git a/plugins/hyperframes/skills/gsap/references/effects.md b/plugins/hyperframes/skills/gsap/references/effects.md
new file mode 100644
index 00000000..82c0ebaf
--- /dev/null
+++ b/plugins/hyperframes/skills/gsap/references/effects.md
@@ -0,0 +1,297 @@
+# GSAP Effects for HyperFrames
+
+Drop-in animation patterns for HyperFrames compositions. Each effect is self-contained with HTML, CSS, and code.
+
+All effects follow HyperFrames composition rules — deterministic, no randomness, timelines registered via `window.__timelines`.
+
+## Table of Contents
+
+- [Typewriter](#typewriter)
+- [Audio Visualizer](#audio-visualizer)
+
+---
+
+## Typewriter
+
+Reveal text character by character using GSAP's TextPlugin.
+
+### Required Plugin
+
+```html
+
+
+
+```
+
+### Basic Typewriter
+
+```js
+const text = "Hello, world!";
+const cps = 10; // chars per second: 3-5 dramatic, 8-12 conversational, 15-20 energetic
+tl.to(
+ "#typed-text",
+ { text: { value: text }, duration: text.length / cps, ease: "none" },
+ startTime,
+);
+```
+
+### With Blinking Cursor
+
+Three rules:
+
+1. **One cursor visible at a time** — hide previous before showing next.
+2. **Cursor must blink when idle** — after typing, during pauses.
+3. **No gap between text and cursor** — elements must be flush in HTML.
+
+```html
+|
+```
+
+```css
+@keyframes blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+}
+.cursor-blink {
+ animation: blink 0.8s step-end infinite;
+}
+.cursor-solid {
+ animation: none;
+ opacity: 1;
+}
+.cursor-hide {
+ animation: none;
+ opacity: 0;
+}
+```
+
+Pattern: blink → solid (typing starts) → type → solid → blink (typing done).
+
+```js
+tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], startTime);
+tl.to("#typed-text", { text: { value: text }, duration: dur, ease: "none" }, startTime);
+tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], startTime + dur);
+```
+
+### Backspacing
+
+TextPlugin removes from front — wrong for backspace. Use manual substring removal:
+
+```js
+function backspace(tl, selector, word, startTime, cps) {
+ const el = document.querySelector(selector);
+ const interval = 1 / cps;
+ for (let i = word.length - 1; i >= 0; i--) {
+ tl.call(
+ () => {
+ el.textContent = word.slice(0, i);
+ },
+ [],
+ startTime + (word.length - i) * interval,
+ );
+ }
+ return word.length * interval;
+}
+```
+
+### Spacing with Static Text
+
+When a typewriter word sits next to static text, use `margin-left` on a wrapper span. Don't use flex gap (spaces cursor from text) or trailing space in static text (collapses when dynamic is empty).
+
+```html
+
+ Ship something
+ |
+
+```
+
+### Word Rotation
+
+Type → hold → backspace → next word. Cursor blinks during every idle moment (holds, after backspace).
+
+```js
+words.forEach((word, i) => {
+ const typeDur = word.length / 10;
+ // Solid while typing
+ tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
+ tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset);
+ // Blink during hold
+ tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + typeDur);
+ offset += typeDur + 1.5; // hold
+
+ if (i < words.length - 1) {
+ tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
+ const clearDur = backspace(tl, el, word, offset, 20);
+ tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + clearDur);
+ offset += clearDur + 0.3;
+ }
+});
+```
+
+### Appending Words
+
+Build a sentence word-by-word into the same element:
+
+```js
+let accumulated = "";
+words.forEach((word) => {
+ const target = accumulated + (accumulated ? " " : "") + word;
+ const newChars = target.length - accumulated.length;
+ tl.to("#typed-text", { text: { value: target }, duration: newChars / 10, ease: "none" }, offset);
+ accumulated = target;
+ offset += newChars / 10 + 0.3;
+});
+```
+
+### Multi-Line Cursor Handoff
+
+When handing off between typewriter lines: hide previous → blink new → pause → solid when typing. Never go hidden→solid (skips idle state).
+
+```js
+tl.call(
+ () => {
+ prevCursor.classList.replace("cursor-blink", "cursor-hide");
+ nextCursor.classList.replace("cursor-hide", "cursor-blink");
+ },
+ [],
+ handoffTime,
+);
+
+const typeStart = handoffTime + 0.5; // brief blink pause
+tl.call(() => nextCursor.classList.replace("cursor-blink", "cursor-solid"), [], typeStart);
+tl.to("#next-text", { text: { value: text }, duration: dur, ease: "none" }, typeStart);
+tl.call(() => nextCursor.classList.replace("cursor-solid", "cursor-blink"), [], typeStart + dur);
+```
+
+### Timing Guide
+
+| CPS | Feel | Good for |
+| ----- | ---------------- | -------------------------- |
+| 3-5 | Slow, deliberate | Dramatic reveals, suspense |
+| 8-12 | Natural typing | Dialogue, narration |
+| 15-20 | Fast, energetic | Tech demos, code |
+| 30+ | Near-instant | Filling long blocks |
+
+---
+
+## Audio Visualizer
+
+Pre-extract audio data, drive canvas/DOM rendering from GSAP timeline.
+
+### Extract Audio Data
+
+```bash
+python scripts/extract-audio-data.py audio.mp3 -o audio-data.json
+python scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json
+```
+
+Requires ffmpeg and numpy.
+
+### Data Format
+
+```json
+{
+ "fps": 30, "totalFrames": 5415,
+ "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3, ...] }]
+}
+```
+
+- **rms** (0-1): overall loudness, normalized across track
+- **bands[]** (0-1): frequency magnitudes. Index 0 = bass, higher = treble. Each normalized independently.
+
+### Loading the Data
+
+```js
+// Option A: inline (small files, under ~500KB)
+var AUDIO_DATA = {
+ /* paste audio-data.json contents */
+};
+
+// Option B: sync XHR (large files — must be synchronous for deterministic timeline construction)
+var xhr = new XMLHttpRequest();
+xhr.open("GET", "audio-data.json", false);
+xhr.send();
+var AUDIO_DATA = JSON.parse(xhr.responseText);
+```
+
+**Do NOT use async `fetch()` to load audio data.** HyperFrames requires synchronous timeline construction — the capture engine reads `window.__timelines` synchronously after page load. Building timelines inside `.then()` callbacks means the timeline isn't ready when capture starts.
+
+### Rendering Approaches
+
+**Canvas 2D** (most common — bars, waveforms, circles, gradients):
+
+```js
+for (let f = 0; f < AUDIO_DATA.totalFrames; f++) {
+ tl.call(
+ () => {
+ const frame = AUDIO_DATA.frames[f];
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ // draw using frame.rms and frame.bands
+ },
+ [],
+ f / AUDIO_DATA.fps,
+ );
+}
+```
+
+**WebGL / Three.js** — HyperFrames patches `THREE.Clock` for deterministic time. Update uniforms from audio data each frame.
+
+**DOM Elements** — fine for < 20 elements, less performant than Canvas for many.
+
+### Spatial Mapping
+
+- **Horizontal**: bass left, treble right (iterate bands left-to-right)
+- **Vertical**: bass bottom, treble top
+- **Circular**: bass at 12 o'clock, wrap clockwise; mirror for full circle
+
+### Smoothing
+
+```js
+let prev = null;
+const smoothing = 0.25; // 0.1-0.2 snappy, 0.3-0.5 flowing
+function smooth(f) {
+ const raw = AUDIO_DATA.frames[f];
+ if (!prev) {
+ prev = { rms: raw.rms, bands: [...raw.bands] };
+ return prev;
+ }
+ prev = {
+ rms: prev.rms * smoothing + raw.rms * (1 - smoothing),
+ bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)),
+ };
+ return prev;
+}
+```
+
+### Motion Principles
+
+- **Bass drives big moves** — scale, glow, position shifts
+- **Treble drives detail** — shimmer, flicker, edge effects
+- **RMS drives globals** — background brightness, overall energy
+- Pick 2-3 properties to animate. More looks noisy.
+- Keep minimums above zero — quiet sections need life.
+
+### Band Count
+
+| Bands | Detail | Good for |
+| ----- | --------- | -------------------------- |
+| 4 | Low | Background glow, pulsing |
+| 8 | Medium | Bar charts, basic spectrum |
+| 16 | High | Detailed EQ (default) |
+| 32 | Very high | Dense radial layouts |
+
+### Layering
+
+Layer multiple canvases with CSS z-index for depth — a background layer driven by bass/rms and a foreground layer driven by individual bands creates depth without complexity.
+
+```html
+
+
+```
diff --git a/plugins/hyperframes/skills/gsap/scripts/extract-audio-data.py b/plugins/hyperframes/skills/gsap/scripts/extract-audio-data.py
new file mode 100644
index 00000000..b4efba78
--- /dev/null
+++ b/plugins/hyperframes/skills/gsap/scripts/extract-audio-data.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""
+Extract per-frame audio visualization data from an audio or video file.
+
+Outputs JSON with RMS amplitude and frequency band data at the target FPS,
+ready to embed in a HyperFrames composition.
+
+Usage:
+ python extract-audio-data.py input.mp3 -o audio-data.json
+ python extract-audio-data.py input.mp4 --fps 30 --bands 16 -o audio-data.json
+
+Requirements:
+ - Python 3.9+
+ - ffmpeg (for decoding audio)
+ - numpy (pip install numpy)
+"""
+
+import argparse
+import json
+import subprocess
+import sys
+
+import numpy as np
+
+# ---------------------------------------------------------------------------
+# FFT parameters
+#
+# A 4096-sample window gives ~10.8 Hz per bin at 44100Hz — enough to resolve
+# low-frequency bands cleanly. The per-frame audio slice (44100/30 = 1470
+# samples at 30fps) is too small and causes low bands to map to the same bins.
+#
+# Frequency range 30Hz–16kHz covers the useful range for music. Below 30Hz is
+# sub-bass most speakers can't reproduce; above 16kHz is noise/harmonics that
+# don't contribute to perceived rhythm or melody.
+# ---------------------------------------------------------------------------
+
+SAMPLE_RATE = 44100
+FFT_SIZE = 4096
+MIN_FREQ = 30.0
+MAX_FREQ = 16000.0
+
+
+def decode_audio(path: str) -> np.ndarray:
+ """Decode audio to mono float32 samples via ffmpeg."""
+ cmd = [
+ "ffmpeg", "-i", path,
+ "-vn", "-ac", "1", "-ar", str(SAMPLE_RATE),
+ "-f", "s16le", "-acodec", "pcm_s16le",
+ "-loglevel", "error",
+ "pipe:1",
+ ]
+ result = subprocess.run(cmd, capture_output=True)
+ if result.returncode != 0:
+ print(f"ffmpeg error: {result.stderr.decode()}", file=sys.stderr)
+ sys.exit(1)
+ return np.frombuffer(result.stdout, dtype=np.int16).astype(np.float32) / 32768.0
+
+
+def compute_band_edges(n_bands: int) -> np.ndarray:
+ """Logarithmically-spaced frequency band edges from MIN_FREQ to MAX_FREQ."""
+ return np.array([
+ MIN_FREQ * (MAX_FREQ / MIN_FREQ) ** (i / n_bands)
+ for i in range(n_bands + 1)
+ ])
+
+
+def compute_fft_bands(
+ windowed: np.ndarray, freq_per_bin: float, n_bins: int,
+ band_edges: np.ndarray, n_bands: int,
+) -> np.ndarray:
+ """Compute peak magnitude in logarithmically-spaced frequency bands."""
+ magnitudes = np.abs(np.fft.rfft(windowed))
+
+ bands = np.zeros(n_bands)
+ for b in range(n_bands):
+ low_bin = max(0, int(band_edges[b] / freq_per_bin))
+ high_bin = min(n_bins, int(band_edges[b + 1] / freq_per_bin))
+ if high_bin <= low_bin:
+ high_bin = low_bin + 1
+ # Clamp to valid range to avoid empty slices
+ low_bin = min(low_bin, n_bins - 1)
+ high_bin = min(high_bin, n_bins)
+ bands[b] = np.max(magnitudes[low_bin:high_bin])
+
+ return bands
+
+
+def extract(path: str, fps: int, n_bands: int) -> dict:
+ """Extract per-frame audio data."""
+ print(f"Decoding audio from {path}...", file=sys.stderr)
+ samples = decode_audio(path)
+ duration = len(samples) / SAMPLE_RATE
+ frame_step = SAMPLE_RATE // fps
+ total_frames = int(duration * fps)
+
+ print(f"Duration: {duration:.1f}s, {total_frames} frames at {fps}fps", file=sys.stderr)
+ print(f"FFT window: {FFT_SIZE} samples ({SAMPLE_RATE / FFT_SIZE:.1f} Hz/bin)", file=sys.stderr)
+ print(f"Frequency range: {MIN_FREQ:.0f}-{MAX_FREQ:.0f} Hz, {n_bands} bands", file=sys.stderr)
+
+ # Precompute constants
+ hann = np.hanning(FFT_SIZE)
+ band_edges = compute_band_edges(n_bands)
+ freq_per_bin = SAMPLE_RATE / FFT_SIZE
+ n_bins = FFT_SIZE // 2 + 1
+ half_fft = FFT_SIZE // 2
+
+ # Pass 1: extract raw values
+ rms_values = np.zeros(total_frames)
+ band_values = np.zeros((total_frames, n_bands))
+
+ for f in range(total_frames):
+ # RMS from the frame's audio slice
+ rms_start = f * frame_step
+ rms_end = rms_start + frame_step
+ frame_slice = samples[rms_start:min(rms_end, len(samples))]
+ if len(frame_slice) > 0:
+ rms_values[f] = np.sqrt(np.mean(frame_slice ** 2))
+
+ # FFT from a centered 4096-sample window
+ center = rms_start + frame_step // 2
+ win_start = center - half_fft
+ win_end = center + half_fft
+
+ if win_start >= 0 and win_end <= len(samples):
+ window = samples[win_start:win_end] * hann
+ else:
+ # Zero-pad at edges
+ padded = np.zeros(FFT_SIZE)
+ src_start = max(0, win_start)
+ src_end = min(len(samples), win_end)
+ dst_start = src_start - win_start
+ dst_end = dst_start + (src_end - src_start)
+ padded[dst_start:dst_end] = samples[src_start:src_end]
+ window = padded * hann
+
+ band_values[f] = compute_fft_bands(window, freq_per_bin, n_bins, band_edges, n_bands)
+
+ # Pass 2: normalize
+ peak_rms = rms_values.max() if total_frames > 0 else 1.0
+ if peak_rms > 0:
+ rms_values /= peak_rms
+
+ # Per-band normalization so treble is visible alongside louder bass
+ band_peaks = band_values.max(axis=0)
+ band_peaks[band_peaks == 0] = 1.0
+ band_values /= band_peaks
+
+ # Build output
+ frames = []
+ for f in range(total_frames):
+ frames.append({
+ "time": round(f / fps, 4),
+ "rms": round(float(rms_values[f]), 4),
+ "bands": [round(float(b), 4) for b in band_values[f]],
+ })
+
+ return {
+ "duration": round(duration, 4),
+ "fps": fps,
+ "bands": n_bands,
+ "totalFrames": total_frames,
+ "frames": frames,
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Extract per-frame audio visualization data")
+ parser.add_argument("input", help="Audio or video file")
+ parser.add_argument("-o", "--output", default="audio-data.json", help="Output JSON path")
+ parser.add_argument("--fps", type=int, default=30, help="Frames per second (default: 30)")
+ parser.add_argument("--bands", type=int, default=16, help="Number of frequency bands (default: 16)")
+ args = parser.parse_args()
+
+ if args.fps < 1:
+ parser.error("--fps must be at least 1")
+ if args.bands < 1:
+ parser.error("--bands must be at least 1")
+
+ data = extract(args.input, args.fps, args.bands)
+
+ with open(args.output, "w") as f:
+ json.dump(data, f)
+
+ print(f"Wrote {args.output} ({data['totalFrames']} frames, {data['bands']} bands)", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/hyperframes/skills/hyperframes-cli/SKILL.md b/plugins/hyperframes/skills/hyperframes-cli/SKILL.md
new file mode 100644
index 00000000..ba14cfe6
--- /dev/null
+++ b/plugins/hyperframes/skills/hyperframes-cli/SKILL.md
@@ -0,0 +1,114 @@
+---
+name: hyperframes-cli
+description: HyperFrames CLI tool — hyperframes init, lint, preview, render, transcribe, tts, doctor, browser, info, upgrade, compositions, docs, benchmark. Use when scaffolding a project, linting or validating compositions, previewing in the studio, rendering to video, transcribing audio, generating TTS, or troubleshooting the HyperFrames environment.
+---
+
+# HyperFrames CLI
+
+Everything runs through `npx hyperframes`. Requires Node.js >= 22 and FFmpeg.
+
+## Workflow
+
+1. **Scaffold** — `npx hyperframes init my-video`
+2. **Write** — author HTML composition (see the `hyperframes` skill)
+3. **Lint** — `npx hyperframes lint`
+4. **Preview** — `npx hyperframes preview`
+5. **Render** — `npx hyperframes render`
+
+Lint before preview — catches missing `data-composition-id`, overlapping tracks, unregistered timelines.
+
+## Scaffolding
+
+```bash
+npx hyperframes init my-video # interactive wizard
+npx hyperframes init my-video --example warm-grain # pick an example
+npx hyperframes init my-video --video clip.mp4 # with video file
+npx hyperframes init my-video --audio track.mp3 # with audio file
+npx hyperframes init my-video --non-interactive # skip prompts (CI/agents)
+```
+
+Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decision-tree`, `kinetic-type`, `product-promo`, `nyt-graph`.
+
+`init` creates the right file structure, copies media, transcribes audio with Whisper, and installs AI coding skills. Use it instead of creating files by hand.
+
+## Linting
+
+```bash
+npx hyperframes lint # current directory
+npx hyperframes lint ./my-project # specific project
+npx hyperframes lint --verbose # info-level findings
+npx hyperframes lint --json # machine-readable
+```
+
+Lints `index.html` and all files in `compositions/`. Reports errors (must fix), warnings (should fix), and info (with `--verbose`).
+
+## Previewing
+
+```bash
+npx hyperframes preview # serve current directory
+npx hyperframes preview --port 4567 # custom port (default 3002)
+```
+
+Hot-reloads on file changes. Opens the studio in your browser automatically.
+
+## Rendering
+
+```bash
+npx hyperframes render # standard MP4
+npx hyperframes render --output final.mp4 # named output
+npx hyperframes render --quality draft # fast iteration
+npx hyperframes render --fps 60 --quality high # final delivery
+npx hyperframes render --format webm # transparent WebM
+npx hyperframes render --docker # byte-identical
+```
+
+| Flag | Options | Default | Notes |
+| -------------- | --------------------- | -------------------------- | --------------------------- |
+| `--output` | path | renders/name_timestamp.mp4 | Output path |
+| `--fps` | 24, 30, 60 | 30 | 60fps doubles render time |
+| `--quality` | draft, standard, high | standard | draft for iterating |
+| `--format` | mp4, webm | mp4 | WebM supports transparency |
+| `--workers` | 1-8 or auto | auto | Each spawns Chrome |
+| `--docker` | flag | off | Reproducible output |
+| `--gpu` | flag | off | GPU-accelerated encoding |
+| `--strict` | flag | off | Fail on lint errors |
+| `--strict-all` | flag | off | Fail on errors AND warnings |
+
+**Quality guidance:** `draft` while iterating, `standard` for review, `high` for final delivery.
+
+## Transcription
+
+```bash
+npx hyperframes transcribe audio.mp3
+npx hyperframes transcribe video.mp4 --model medium.en --language en
+npx hyperframes transcribe subtitles.srt # import existing
+npx hyperframes transcribe subtitles.vtt
+npx hyperframes transcribe openai-response.json
+```
+
+## Text-to-Speech
+
+```bash
+npx hyperframes tts "Text here" --voice af_nova --output narration.wav
+npx hyperframes tts script.txt --voice bf_emma
+npx hyperframes tts --list # show all voices
+```
+
+## Troubleshooting
+
+```bash
+npx hyperframes doctor # check environment (Chrome, FFmpeg, Node, memory)
+npx hyperframes browser # manage bundled Chrome
+npx hyperframes info # version and environment details
+npx hyperframes upgrade # check for updates
+```
+
+Run `doctor` first if rendering fails. Common issues: missing FFmpeg, missing Chrome, low memory.
+
+## Other
+
+```bash
+npx hyperframes compositions # list compositions in project
+npx hyperframes docs # open documentation
+npx hyperframes benchmark . # benchmark render performance
+```
diff --git a/plugins/hyperframes/skills/hyperframes-registry/SKILL.md b/plugins/hyperframes/skills/hyperframes-registry/SKILL.md
new file mode 100644
index 00000000..00566657
--- /dev/null
+++ b/plugins/hyperframes/skills/hyperframes-registry/SKILL.md
@@ -0,0 +1,104 @@
+---
+name: hyperframes-registry
+description: Install and wire registry blocks and components into HyperFrames compositions. Use when running hyperframes add, installing a block or component, wiring an installed item into index.html, or working with hyperframes.json. Covers the add command, install locations, block sub-composition wiring, component snippet merging, and registry discovery.
+---
+
+# HyperFrames Registry
+
+The registry provides reusable blocks and components installable via `hyperframes add `.
+
+- **Blocks** — standalone sub-compositions (own dimensions, duration, timeline). Included via `data-composition-src` in a host composition.
+- **Components** — effect snippets (no own dimensions). Pasted directly into a host composition's HTML.
+
+## When to use this skill
+
+- User mentions `hyperframes add`, "block", "component", or `hyperframes.json`
+- Output from `hyperframes add` appears in the session (file paths, clipboard snippet)
+- You need to wire an installed item into an existing composition
+- You want to discover what's available in the registry
+
+## Quick reference
+
+```bash
+hyperframes add data-chart # install a block
+hyperframes add grain-overlay # install a component
+hyperframes add shimmer-sweep --dir . # target a specific project
+hyperframes add data-chart --json # machine-readable output
+hyperframes add data-chart --no-clipboard # skip clipboard (CI/headless)
+```
+
+After install, the CLI prints which files were written and a snippet to paste into your host composition. The snippet is a starting point — you'll need to add `data-composition-id` (must match the block's internal composition ID), `data-start`, and `data-track-index` attributes when wiring blocks.
+
+Note: `hyperframes add` only works for blocks and components. For examples, use `hyperframes init --example ` instead.
+
+## Install locations
+
+Blocks install to `compositions/.html` by default.
+Components install to `compositions/components/.html` by default.
+
+These paths are configurable in `hyperframes.json`:
+
+```json
+{
+ "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
+ "paths": {
+ "blocks": "compositions",
+ "components": "compositions/components",
+ "assets": "assets"
+ }
+}
+```
+
+See [install-locations.md](./references/install-locations.md) for full details.
+
+## Wiring blocks
+
+Blocks are standalone compositions — include them via `data-composition-src` in your host `index.html`:
+
+```html
+
+```
+
+Key attributes:
+
+- `data-composition-src` — path to the block HTML file
+- `data-composition-id` — must match the block's internal ID
+- `data-start` — when the block appears in the host timeline (seconds)
+- `data-duration` — how long the block plays
+- `data-width` / `data-height` — block canvas dimensions
+- `data-track-index` — layer ordering (higher = in front)
+
+See [wiring-blocks.md](./references/wiring-blocks.md) for full details.
+
+## Wiring components
+
+Components are snippets — paste their HTML into your composition's markup, their CSS into your style block, and their JS into your script (if any):
+
+1. Read the installed file (e.g., `compositions/components/grain-overlay.html`)
+2. Copy the HTML elements into your composition's ``
+3. Copy the `
+
+
+
+
+
+
+
+
+