hyperframes: add HTML/CSS/GSAP as a parallel composition runtime

Separates creative grammar (renderer_family) from technical engine
(render_runtime) so HyperFrames can stand alongside Remotion as a
first-class runtime instead of masquerading as a Remotion sub-case.
Locks runtime choice at proposal stage and enforces it end-to-end: the
schemas require it, video_compose routes by it, the reviewer fails
closed on silent swaps, and a parametrized contract test walks every
pipeline manifest to ensure each planning-stage skill explains the
conversation to the user. Adds hyperframes_compose (scaffold/lint/
validate/render/doctor/add_block), a playbook -> CSS style bridge, and
vendored HyperFrames Layer 3 skills from commit d291358, pinned via
PROVENANCE.md for future re-sync. Final_review now records
render_runtime_used and runtime_swap_detected so compose lies are
catchable after the fact.
This commit is contained in:
calesthio
2026-04-18 18:37:00 -07:00
parent 9e17263aa5
commit b4f7ec4eee
118 changed files with 10128 additions and 126 deletions
+136
View File
@@ -0,0 +1,136 @@
---
name: hyperframes-cli
description: HyperFrames CLI tool — hyperframes init, lint, validate, 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.
---
<!--
Vendored into OpenMontage from C:\Users\ishan\Documents\hyperframes\skills\hyperframes-cli\SKILL.md
Source commit: d291358 (2026-04-17)
See .agents/skills/hyperframes/PROVENANCE.md for re-sync instructions.
OpenMontage-local edit: added `validate` to the top-line command list and a
dedicated "Validation" section — upstream omits it even though the CLI ships it
and `hyperframes validate` is the real browser-based contract check (contrast,
timelines, assets) that runs before render.
-->
# 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` (static checks)
4. **Validate**`npx hyperframes validate` (browser-based runtime checks + contrast audit)
5. **Preview**`npx hyperframes preview`
6. **Render**`npx hyperframes render`
Lint catches static issues (missing `data-composition-id`, overlapping tracks, unregistered timelines). Validate catches runtime/visual issues by seeking into the paused composition in a real browser (contrast, broken overlays, missing assets). Run both before render.
## Validation
```bash
npx hyperframes validate # full validation (static + contrast + contract)
npx hyperframes validate --no-contrast # skip WCAG contrast audit when iterating fast
npx hyperframes validate --json # machine-readable output
```
Validation seeks to several timestamps in the paused composition, screenshots the page, samples pixels behind every text element to compute WCAG contrast ratios, and verifies runtime contracts (`window.__timelines` registration, `class="clip"` presence, valid `data-*` attributes). Run it before every render — it's cheap and catches issues lint cannot see.
## 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
```
@@ -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 <name>`.
- **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 <dir> --example <name>` instead.
## Install locations
Blocks install to `compositions/<name>.html` by default.
Components install to `compositions/components/<name>.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
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
```
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 `<div data-composition-id="...">`
3. Copy the `<style>` block into your composition's styles
4. Copy any `<script>` content into your composition's script (before your timeline code)
5. If the component exposes GSAP timeline integration (see the comment block in the snippet), add those calls to your timeline
See [wiring-components.md](./references/wiring-components.md) for full details.
## Discovery
Browse available items:
```bash
# Read the registry manifest
curl -s https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json
```
Each item's `registry-item.json` contains: name, type, title, description, tags, dimensions (blocks only), duration (blocks only), and file list.
See [discovery.md](./references/discovery.md) for details on filtering by type and tags.
@@ -0,0 +1,51 @@
# Worked Example: Adding a Block
## Scenario
User has an existing HyperFrames project and wants to add an animated chart alongside their video content.
## Steps
### 1. Install the block
```bash
hyperframes add data-chart
```
### 2. Wire into index.html
```html
<div id="stage" data-composition-id="main" data-width="1920" data-height="1080" data-duration="30">
<video
id="speaker"
src="speaker.mp4"
data-start="0"
data-duration="30"
data-track-index="0"
style="position: absolute; width: 60%; height: 100%; left: 0; top: 0; object-fit: cover;"
></video>
<!-- Data chart appears at 5s in the right 40% of the screen -->
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="5"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
style="position: absolute; right: 0; top: 0; width: 40%; height: 100%;"
></div>
</div>
```
### 3. Lint and preview
```bash
hyperframes lint
hyperframes preview
```
### 4. Customize (optional)
Edit `compositions/data-chart.html` — data arrays are at the top of the script, colors are in the CSS rules scoped under `[data-composition-id="data-chart"]`.
@@ -0,0 +1,73 @@
# Worked Example: Adding a Component
## Scenario
User wants to add a shimmer light sweep effect to their title text.
## Steps
### 1. Install the component
```bash
hyperframes add shimmer-sweep
```
### 2. Read the snippet
Open `compositions/components/shimmer-sweep.html` and read the comment header.
### 3. Wire into your composition
**HTML** — wrap target elements:
```html
<div class="shimmer-sweep-target" style="--shimmer-color: rgba(255, 255, 255, 0.5)">
<h1 class="title">AI-Powered Video</h1>
</div>
```
**CSS** — paste the `.shimmer-sweep-target` and `.shimmer-mask` rules from the snippet.
**JS** — paste the auto-injection script (before timeline code):
```js
document.querySelectorAll(".shimmer-sweep-target").forEach((el) => {
if (!el.querySelector(".shimmer-mask")) {
const mask = document.createElement("div");
mask.className = "shimmer-mask";
el.appendChild(mask);
}
});
```
**Timeline** — add the sweep:
```js
tl.fromTo(
".shimmer-sweep-target",
{
"--shimmer-pos": "-20%",
},
{
"--shimmer-pos": "120%",
duration: 1.2,
ease: "power2.inOut",
stagger: 0.15,
},
1.5,
);
```
### 4. Lint and preview
```bash
hyperframes lint
hyperframes preview
```
### 5. Customize
- `--shimmer-color`: highlight color per element
- `--shimmer-width`: light band width (default 20%)
- `--shimmer-angle`: sweep direction (default 120deg)
- Timeline `duration`, `ease`, `stagger`: control speed and feel
@@ -0,0 +1,54 @@
# The demo.html Convention
## Why components ship demo.html
Every component in the registry ships a companion `demo.html` file alongside its snippet. The demo serves two purposes:
1. **Preview fixture** — the CI preview pipeline renders the demo to generate thumbnail images and preview videos for the catalog docs page.
2. **Usage example** — the demo shows the component effect applied to representative content, serving as a working reference.
## Demo structure
A demo is a complete, standalone HTML composition:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Component Name — Demo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
/* reset + canvas size */
</style>
</head>
<body>
<div data-composition-id="<name>-demo" data-width="1920" data-height="1080" data-duration="N">
<!-- Demo content showing the effect -->
<!-- Component snippet inlined here -->
</div>
<script>
// GSAP timeline demonstrating the effect
window.__timelines = window.__timelines || {};
window.__timelines["<name>-demo"] = tl;
</script>
</body>
</html>
```
Key conventions:
- `data-composition-id` is `<component-name>-demo` to avoid collisions
- The demo is self-contained — all CSS and JS from the snippet is inlined
- The GSAP timeline is registered on `window.__timelines`
- Duration should be long enough to showcase the effect (typically 5-8 seconds)
## Blocks don't need demo.html
Blocks are already standalone compositions that can be rendered directly. Only components need the demo wrapper.
## Demos are not installed
The `demo.html` is NOT installed by `hyperframes add` — it exists only in the registry for preview generation and as a reference.
@@ -0,0 +1,53 @@
# Registry Discovery
## Reading the registry manifest
The top-level `registry.json` lists all available items:
```bash
curl -s https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/registry.json
```
Each entry has `name` and `type` (`hyperframes:example`, `hyperframes:block`, or `hyperframes:component`).
## Reading an item's manifest
Each item has a `registry-item.json` with full metadata:
```
<base>/<type-dir>/<name>/registry-item.json
```
Where `<type-dir>` is `examples`, `blocks`, or `components`.
## Item manifest fields
| Field | Type | Required | Description |
| ---------------------- | -------- | -------- | ---------------------------------------------- |
| `name` | string | yes | Kebab-case identifier |
| `type` | string | yes | `hyperframes:block` or `hyperframes:component` |
| `title` | string | yes | Human-readable title |
| `description` | string | yes | One-line description |
| `tags` | string[] | no | Filter tags (e.g., `["data", "chart"]`) |
| `dimensions` | object | blocks | `{ width, height }` — blocks only |
| `duration` | number | blocks | Duration in seconds — blocks only |
| `files` | array | yes | Files to install (`path`, `target`, `type`) |
| `registryDependencies` | string[] | no | Other registry items this depends on |
## Available items
### Blocks
| Name | Description | Tags |
| ------------ | ----------------------------------------------- | ------------------------------- |
| `data-chart` | Animated bar + line chart with staggered reveal | data, chart, statistics |
| `flowchart` | Decision tree with SVG connectors and cursor | diagram, flowchart, interactive |
| `logo-outro` | Cinematic logo reveal with tagline | branding, outro, logo |
### Components
| Name | Description | Tags |
| -------------------- | --------------------------------------- | -------------------------------- |
| `grain-overlay` | Animated film grain texture overlay | texture, grain, overlay, film |
| `shimmer-sweep` | CSS gradient light sweep for AI accents | text, shimmer, highlight, effect |
| `grid-pixelate-wipe` | Grid dissolve transition between scenes | transition, wipe, grid, pixelate |
@@ -0,0 +1,45 @@
# Install Locations
## Default paths
| Item type | Default install path | Configured by |
| --------- | ------------------------------------- | ----------------------------------- |
| Block | `compositions/<name>.html` | `hyperframes.json#paths.blocks` |
| Component | `compositions/components/<name>.html` | `hyperframes.json#paths.components` |
## How path remapping works
The `target` field in each item's `registry-item.json` specifies a default install path. The `add` command remaps the prefix based on `hyperframes.json#paths`:
- Block targets starting with `compositions/` get remapped to `<paths.blocks>/`
- Component targets starting with `compositions/components/` get remapped to `<paths.components>/`
## hyperframes.json
Created automatically by `hyperframes init`. If it doesn't exist when you run `add`, the CLI creates it with defaults:
```json
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
}
}
```
## Custom layouts
To install blocks into a `scenes/` directory instead of `compositions/`:
```json
{
"paths": {
"blocks": "scenes"
}
}
```
Then `hyperframes add data-chart` writes to `scenes/data-chart.html` instead of `compositions/data-chart.html`. The snippet output reflects the remapped path.
@@ -0,0 +1,91 @@
# Wiring Blocks
Blocks are standalone compositions with their own `data-composition-id`, dimensions, duration, and GSAP timeline. Include them in a host composition using `data-composition-src` on a `<div>`.
## Basic wiring
After `hyperframes add data-chart`, wire it into your `index.html`:
```html
<div id="stage" data-composition-id="main" data-width="1920" data-height="1080" data-duration="20">
<video id="a-roll" src="video.mp4" data-start="0" data-duration="20" data-track-index="0"></video>
<!-- Block: appears at 2s, plays for 15s, on layer 1 -->
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
</div>
```
## Required attributes
| Attribute | Description |
| ---------------------- | -------------------------------------------------------------------- |
| `data-composition-src` | Path to the block HTML file (relative to index.html) |
| `data-composition-id` | Unique ID matching the block's internal composition ID |
| `data-start` | When the block appears in the host timeline (seconds) |
| `data-duration` | How long the block plays (seconds, at most the block's own duration) |
| `data-track-index` | Layer ordering — higher numbers render in front |
| `data-width` | Block canvas width (match the block's dimensions) |
| `data-height` | Block canvas height (match the block's dimensions) |
## Timeline coordination
The block's internal GSAP timeline runs independently from the host timeline. The HyperFrames runtime loads the sub-composition, finds its `window.__timelines` registration, and seeks the block in sync with the host, offset by `data-start`. You do NOT need to reference the block's timeline in your host's GSAP code.
## Positioning blocks
To position a block in a specific area of the screen, add CSS:
```html
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="2"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
style="position: absolute; right: 0; top: 0; width: 40%; height: 100%;"
></div>
```
## Multiple blocks
Include multiple blocks sequentially or overlapping:
```html
<div
data-composition-id="data-chart"
data-composition-src="compositions/data-chart.html"
data-start="0"
data-duration="15"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
<div
data-composition-id="flowchart"
data-composition-src="compositions/flowchart.html"
data-start="15"
data-duration="12"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
<div
data-composition-id="logo-outro"
data-composition-src="compositions/logo-outro.html"
data-start="27"
data-duration="6"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
```
@@ -0,0 +1,77 @@
# Wiring Components
Components are effect snippets — HTML, CSS, and optionally JS that you merge directly into an existing composition. Unlike blocks, components have no standalone timeline; they participate in the host composition's timeline.
## General process
1. Run `hyperframes add <component-name>`
2. Open the installed file (e.g., `compositions/components/grain-overlay.html`)
3. Read the comment header for usage instructions
4. Copy the parts into your host composition:
- **HTML elements** — inside your `<div data-composition-id="...">`
- **CSS styles** — into your composition's `<style>` block
- **JS setup** — into your composition's `<script>`, before your timeline code
- **Timeline calls** — into your GSAP timeline (if the component exposes them)
## Example: grain-overlay (CSS-only, no timeline integration)
```html
<!-- Paste the overlay div into your composition -->
<div
id="grain-overlay"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 100;"
>
<div class="grain-texture"></div>
</div>
```
Then paste the CSS keyframes and `.grain-texture` rule into your styles. No GSAP timeline calls needed — the grain animates via CSS `@keyframes`.
## Example: shimmer-sweep (needs timeline integration)
Wrap target elements, paste CSS and JS, then drive the sweep from your timeline:
```js
tl.fromTo(
".shimmer-sweep-target",
{
"--shimmer-pos": "-20%",
},
{
"--shimmer-pos": "120%",
duration: 1.2,
ease: "power2.inOut",
stagger: 0.15,
},
2.0,
);
```
## Example: grid-pixelate-wipe (scene transition)
Paste the overlay HTML and CSS, then drive `.grid-cell` scale in your timeline:
```js
// Cover screen
tl.to(
".grid-cell",
{ scale: 1, duration: 0.6, stagger: { amount: 0.6, from: "center" }, ease: "power2.inOut" },
5.0,
);
// Swap scenes
tl.set("#scene-a", { opacity: 0 }, 5.6);
tl.set("#scene-b", { opacity: 1 }, 5.6);
// Reveal
tl.to(
".grid-cell",
{ scale: 0, duration: 0.6, stagger: { amount: 0.6, from: "edges" }, ease: "power2.inOut" },
5.6,
);
```
## Key principles
- Components inherit the host composition's dimensions and duration
- Place component HTML at the appropriate z-index relative to your content
- Read the comment header in each snippet for customizable values
- Run `hyperframes lint` after wiring to catch structural issues
+59
View File
@@ -0,0 +1,59 @@
# Provenance — HyperFrames Layer 3 Skills
These skills are **vendored** from the upstream HyperFrames monorepo. Do not
edit them expecting the changes to survive a re-sync — changes need to go
upstream, or be recorded in the local-edit log below.
## Source
- Repo: https://github.com/heygen-com/hyperframes
- Local clone: `C:\Users\ishan\Documents\hyperframes`
- Vendored commit: `d291358`
- Vendored date: `2026-04-17`
## Mirrored directories
| OpenMontage path | Upstream path |
| ---------------------------------------------- | ----------------------------------------- |
| `.agents/skills/hyperframes/` | `skills/hyperframes/` |
| `.agents/skills/hyperframes-cli/` | `skills/hyperframes-cli/` |
| `.agents/skills/hyperframes-registry/` | `skills/hyperframes-registry/` |
| `.agents/skills/website-to-hyperframes/` | `skills/website-to-hyperframes/` |
The `gsap` upstream skill is NOT re-vendored — OpenMontage already ships its
own GSAP skill family under `.agents/skills/gsap*/`.
## Local edits
Any divergence from upstream is noted at the top of the edited file as an
HTML comment starting with `OpenMontage-local`. Current edits:
- `hyperframes-cli/SKILL.md` — added `validate` to the command list and a
dedicated Validation section. Upstream omits it, but the CLI ships it and
OpenMontage's HyperFrames runtime path relies on `hyperframes validate` as
a real browser-based contract check before render.
## Re-sync procedure
```bash
# From the hyperframes clone
cd C:/Users/ishan/Documents/hyperframes
git pull
# From OpenMontage
cd C:/Users/ishan/Documents/OpenMontage
rm -rf .agents/skills/hyperframes .agents/skills/hyperframes-cli \
.agents/skills/hyperframes-registry .agents/skills/website-to-hyperframes
cp -r C:/Users/ishan/Documents/hyperframes/skills/hyperframes .agents/skills/
cp -r C:/Users/ishan/Documents/hyperframes/skills/hyperframes-cli .agents/skills/
cp -r C:/Users/ishan/Documents/hyperframes/skills/hyperframes-registry .agents/skills/
cp -r C:/Users/ishan/Documents/hyperframes/skills/website-to-hyperframes .agents/skills/
# Then re-apply the local edits listed above and bump the vendored commit SHA.
```
## Why we vendor instead of referencing the upstream clone directly
1. OpenMontage contributors may not have the HyperFrames monorepo on disk.
2. Skills must be readable from the OpenMontage tree for agent discovery.
3. We want deterministic knowledge — upstream moves; we control when we pick
up changes.
+347
View File
@@ -0,0 +1,347 @@
---
name: hyperframes
description: Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill.
---
# HyperFrames
HTML is the source of truth for video. A composition is an HTML file with `data-*` attributes for timing, a GSAP timeline for animation, and CSS for appearance. The framework handles clip visibility, media playback, and timeline sync.
## Approach
Before writing HTML, think at a high level:
1. **What** — what should the viewer experience? Identify the narrative arc, key moments, and emotional beats.
2. **Structure** — how many compositions, which are sub-compositions vs inline, what tracks carry what (video, audio, overlays, captions).
3. **Timing** — which clips drive the duration, where do transitions land, what's the pacing.
4. **Layout** — build the end-state first. See "Layout Before Animation" below.
5. **Animate** — then add motion using the rules below.
For small edits (fix a color, adjust timing, add one element), skip straight to the rules.
### Visual Identity Gate
<HARD-GATE>
Before writing ANY composition HTML, you MUST have a visual identity defined. Do NOT write compositions with default or generic colors.
Check in this order:
1. **DESIGN.md exists in the project?** → Read it. Use its exact colors, fonts, motion rules, and "What NOT to Do" constraints.
2. **visual-style.md exists?** → Read it. Apply its `style_prompt_full` and structured fields. (Note: `visual-style.md` is a project-specific file. `visual-styles.md` is the style library with 8 named presets — different files.)
3. **User named a style** (e.g., "Swiss Pulse", "dark and techy", "luxury brand")? → Read [visual-styles.md](./visual-styles.md) for the 8 named presets. Generate a minimal DESIGN.md with: `## Style Prompt` (one paragraph), `## Colors` (3-5 hex values with roles), `## Typography` (1-2 font families), `## What NOT to Do` (3-5 anti-patterns).
4. **None of the above?** → Ask 3 questions before writing any HTML:
- What's the mood? (explosive / cinematic / fluid / technical / chaotic / warm)
- Light or dark canvas?
- Any specific brand colors, fonts, or visual references?
Then generate a minimal DESIGN.md from the answers.
Every composition must trace its palette and typography back to a DESIGN.md, visual-style.md, or explicit user direction. If you're reaching for `#333`, `#3b82f6`, or `Roboto` — you skipped this step.
</HARD-GATE>
For motion defaults, sizing, entrance patterns, and easing — follow [house-style.md](./house-style.md). The house style handles HOW things move. The DESIGN.md handles WHAT things look like.
## Layout Before Animation
Position every element where it should be at its **most visible moment** — the frame where it's fully entered, correctly placed, and not yet exiting. Write this as static HTML+CSS first. No GSAP yet.
**Why this matters:** If you position elements at their animated start state (offscreen, scaled to 0, opacity 0) and tween them to where you think they should land, you're guessing the final layout. Overlaps are invisible until the video renders. By building the end state first, you can see and fix layout problems before adding any motion.
### The process
1. **Identify the hero frame** for each scene — the moment when the most elements are simultaneously visible. This is the layout you build.
2. **Write static CSS** for that frame. The `.scene-content` container MUST fill the full scene using `width: 100%; height: 100%; padding: Npx;` with `display: flex; flex-direction: column; gap: Npx; box-sizing: border-box`. Use padding to push content inward — NEVER `position: absolute; top: Npx` on a content container. Absolute-positioned content containers overflow when content is taller than the remaining space. Reserve `position: absolute` for decoratives only.
3. **Add entrances with `gsap.from()`** — animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there.
4. **Add exits with `gsap.to()`** — animate TO offscreen/invisible FROM the CSS position.
### Example
```css
/* scene-content fills the scene, padding positions content */
.scene-content {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
padding: 120px 160px;
gap: 24px;
box-sizing: border-box;
}
.title {
font-size: 120px;
}
.subtitle {
font-size: 42px;
}
/* Container fills any scene size (1920x1080, 1080x1920, etc).
Padding positions content. Flex + gap handles spacing. */
```
**WRONG — hardcoded dimensions and absolute positioning:**
```css
.scene-content {
position: absolute;
top: 200px;
left: 160px;
width: 1920px;
height: 1080px;
display: flex; /* ... */
}
```
```js
// Step 3: Animate INTO those positions
tl.from(".title", { y: 60, opacity: 0, duration: 0.6, ease: "power3.out" }, 0);
tl.from(".subtitle", { y: 40, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.2);
tl.from(".logo", { scale: 0.8, opacity: 0, duration: 0.4, ease: "power2.out" }, 0.3);
// Step 4: Animate OUT from those positions
tl.to(".title", { y: -40, opacity: 0, duration: 0.4, ease: "power2.in" }, 3);
tl.to(".subtitle", { y: -30, opacity: 0, duration: 0.3, ease: "power2.in" }, 3.1);
tl.to(".logo", { scale: 0.9, opacity: 0, duration: 0.3, ease: "power2.in" }, 3.2);
```
### When elements share space across time
If element A exits before element B enters in the same area, both should have correct CSS positions for their respective hero frames. The timeline ordering guarantees they never visually coexist — but if you skip the layout step, you won't catch the case where they accidentally overlap due to a timing error.
### What counts as intentional overlap
Layered effects (glow behind text, shadow elements, background patterns) and z-stacked designs (card stacks, depth layers) are intentional. The layout step is about catching **unintentional** overlap — two headlines landing on top of each other, a stat covering a label, content bleeding off-frame.
## Data Attributes
### All Clips
| Attribute | Required | Values |
| ------------------ | --------------------------------- | ------------------------------------------------------ |
| `id` | Yes | Unique identifier |
| `data-start` | Yes | Seconds or clip ID reference (`"el-1"`, `"intro + 2"`) |
| `data-duration` | Required for img/div/compositions | Seconds. Video/audio defaults to media duration. |
| `data-track-index` | Yes | Integer. Same-track clips cannot overlap. |
| `data-media-start` | No | Trim offset into source (seconds) |
| `data-volume` | No | 0-1 (default 1) |
`data-track-index` does **not** affect visual layering — use CSS `z-index`.
### Composition Clips
| Attribute | Required | Values |
| ---------------------------- | -------- | -------------------------------------------- |
| `data-composition-id` | Yes | Unique composition ID |
| `data-start` | Yes | Start time (root composition: use `"0"`) |
| `data-duration` | Yes | Takes precedence over GSAP timeline duration |
| `data-width` / `data-height` | Yes | Pixel dimensions (1920x1080 or 1080x1920) |
| `data-composition-src` | No | Path to external HTML file |
## Composition Structure
Sub-compositions loaded via `data-composition-src` use a `<template>` wrapper. **Standalone compositions (the main index.html) do NOT use `<template>`** — they put the `data-composition-id` div directly in `<body>`. Using `<template>` on a standalone file hides all content from the browser and breaks rendering.
Sub-composition structure:
```html
<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<!-- content -->
<style>
[data-composition-id="my-comp"] {
/* scoped styles */
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tweens...
window.__timelines["my-comp"] = tl;
</script>
</div>
</template>
```
Load in root: `<div id="el-1" data-composition-id="my-comp" data-composition-src="compositions/my-comp.html" data-start="0" data-duration="10" data-track-index="1"></div>`
## Video and Audio
Video must be `muted playsinline`. Audio is always a separate `<audio>` element:
```html
<video
id="el-v"
data-start="0"
data-duration="30"
data-track-index="0"
src="video.mp4"
muted
playsinline
></video>
<audio
id="el-a"
data-start="0"
data-duration="30"
data-track-index="2"
src="video.mp4"
data-volume="1"
></audio>
```
## Timeline Contract
- All timelines start `{ paused: true }` — the player controls playback
- Register every timeline: `window.__timelines["<composition-id>"] = tl`
- Framework auto-nests sub-timelines — do NOT manually add them
- Duration comes from `data-duration`, not from GSAP timeline length
- Never create empty tweens to set duration
## Rules (Non-Negotiable)
**Deterministic:** No `Math.random()`, `Date.now()`, or time-based logic. Use a seeded PRNG if you need pseudo-random values (e.g. mulberry32).
**GSAP:** Only animate visual properties (`opacity`, `x`, `y`, `scale`, `rotation`, `color`, `backgroundColor`, `borderRadius`, transforms). Do NOT animate `visibility`, `display`, or call `video.play()`/`audio.play()`.
**Animation conflicts:** Never animate the same property on the same element from multiple timelines simultaneously.
**No `repeat: -1`:** Infinite-repeat timelines break the capture engine. Calculate the exact repeat count from composition duration: `repeat: Math.ceil(duration / cycleDuration) - 1`.
**Synchronous timeline construction:** Never build timelines inside `async`/`await`, `setTimeout`, or Promises. The capture engine reads `window.__timelines` synchronously after page load. Fonts are embedded by the compiler, so they're available immediately — no need to wait for font loading.
**Never do:**
1. Forget `window.__timelines` registration
2. Use video for audio — always muted video + separate `<audio>`
3. Nest video inside a timed div — use a non-timed wrapper
4. Use `data-layer` (use `data-track-index`) or `data-end` (use `data-duration`)
5. Animate video element dimensions — animate a wrapper div
6. Call play/pause/seek on media — framework owns playback
7. Create a top-level container without `data-composition-id`
8. Use `repeat: -1` on any timeline or tween — always finite repeats
9. Build timelines asynchronously (inside `async`, `setTimeout`, `Promise`)
10. Use `gsap.set()` on clip elements from later scenes — they don't exist in the DOM at page load. Use `tl.set(selector, vars, timePosition)` inside the timeline at or after the clip's `data-start` time instead.
11. Use `<br>` in content text — forced line breaks don't account for actual rendered font width. Text that wraps naturally + a `<br>` produces an extra unwanted break, causing overlap. Let text wrap via `max-width` instead. Exception: short display titles where each word is deliberately on its own line (e.g., "THE\nIMMORTAL\nGAME" at 130px).
## Scene Transitions (Non-Negotiable)
Every multi-scene composition MUST follow ALL of these rules. Violating any one of them is a broken composition.
1. **ALWAYS use transitions between scenes.** No jump cuts. No exceptions.
2. **ALWAYS use entrance animations on every scene.** Every element animates IN via `gsap.from()`. No element may appear fully-formed. If a scene has 5 elements, it needs 5 entrance tweens.
3. **NEVER use exit animations** except on the final scene. This means: NO `gsap.to()` that animates opacity to 0, y offscreen, scale to 0, or any other "out" animation before a transition fires. The transition IS the exit. The outgoing scene's content MUST be fully visible at the moment the transition starts.
4. **Final scene only:** The last scene may fade elements out (e.g., fade to black). This is the ONLY scene where `gsap.to(..., { opacity: 0 })` is allowed.
**WRONG — exit animation before transition:**
```js
// BANNED — this empties the scene before the transition can use it
tl.to("#s1-title", { opacity: 0, y: -40, duration: 0.4 }, 6.5);
tl.to("#s1-subtitle", { opacity: 0, duration: 0.3 }, 6.7);
// transition fires on empty frame
```
**RIGHT — entrance only, transition handles exit:**
```js
// Scene 1 entrance animations
tl.from("#s1-title", { y: 50, opacity: 0, duration: 0.7, ease: "power3.out" }, 0.3);
tl.from("#s1-subtitle", { y: 30, opacity: 0, duration: 0.5, ease: "power2.out" }, 0.6);
// NO exit tweens — transition at 7.2s handles the scene change
// Scene 2 entrance animations
tl.from("#s2-heading", { x: -40, opacity: 0, duration: 0.6, ease: "expo.out" }, 8.0);
```
## Animation Guardrails
- Offset first animation 0.1-0.3s (not t=0)
- Vary eases across entrance tweens — use at least 3 different eases per scene
- Don't repeat an entrance pattern within a scene
- Avoid full-screen linear gradients on dark backgrounds (H.264 banding — use radial or solid + localized glow)
- 60px+ headlines, 20px+ body, 16px+ data labels for rendered video
- `font-variant-numeric: tabular-nums` on number columns
When no `visual-style.md` or animation direction is provided, follow [house-style.md](./house-style.md) for aesthetic defaults.
## Typography and Assets
- **Fonts:** Just write the `font-family` you want in CSS — the compiler embeds supported fonts automatically. If a font isn't supported, the compiler warns.
- Add `crossorigin="anonymous"` to external media
- For dynamic text overflow, use `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })`
- All files live at the project root alongside `index.html`; sub-compositions use `../`
## Editing Existing Compositions
- Read the full composition first — match existing fonts, colors, animation patterns
- Only change what was requested
- Preserve timing of unrelated clips
## Output Checklist
- [ ] `npx hyperframes lint` and `npx hyperframes validate` both pass
- [ ] Contrast warnings addressed (see Quality Checks below)
- [ ] Animation choreography verified (see Quality Checks below)
## Quality Checks
### Contrast
`hyperframes validate` runs a WCAG contrast audit by default. It seeks to 5 timestamps, screenshots the page, samples background pixels behind every text element, and computes contrast ratios. Failures appear as warnings:
```
⚠ WCAG AA contrast warnings (3):
· .subtitle "secondary text" — 2.67:1 (need 4.5:1, t=5.3s)
```
If warnings appear:
- On dark backgrounds: brighten the failing color until it clears 4.5:1 (normal text) or 3:1 (large text, 24px+ or 19px+ bold)
- On light backgrounds: darken it
- Stay within the palette family — don't invent a new color, adjust the existing one
- Re-run `hyperframes validate` until clean
Use `--no-contrast` to skip if iterating rapidly and you'll check later.
### Animation Map
After authoring animations, run the animation map to verify choreography:
```bash
node skills/hyperframes/scripts/animation-map.mjs <composition-dir> \
--out <composition-dir>/.hyperframes/anim-map
```
Outputs a single `animation-map.json` with:
- **Per-tween summaries**: `"#card1 animates opacity+y over 0.50s. moves 23px up. fades in. ends at (120, 200)"`
- **ASCII timeline**: Gantt chart of all tweens across the composition duration
- **Stagger detection**: reports actual intervals (`"3 elements stagger at 120ms"`)
- **Dead zones**: periods over 1s with no animation — intentional hold or missing entrance?
- **Element lifecycles**: first/last animation time, final visibility
- **Scene snapshots**: visible element state at 5 key timestamps
- **Flags**: `offscreen`, `collision`, `invisible`, `paced-fast` (under 0.2s), `paced-slow` (over 2s)
Read the JSON. Scan summaries for anything unexpected. Check every flag — fix or justify. Verify the timeline shows the intended choreography rhythm. Re-run after fixes.
Skip on small edits (fixing a color, adjusting one duration). Run on new compositions and significant animation changes.
---
## References (loaded on demand)
- **[references/captions.md](references/captions.md)** — Captions, subtitles, lyrics, karaoke synced to audio. Tone-adaptive style detection, per-word styling, text overflow prevention, caption exit guarantees, word grouping. Read when adding any text synced to audio timing.
- **[references/tts.md](references/tts.md)** — Text-to-speech with Kokoro-82M. Voice selection, speed tuning, TTS+captions workflow. Read when generating narration or voiceover.
- **[references/audio-reactive.md](references/audio-reactive.md)** — Audio-reactive animation: map frequency bands and amplitude to GSAP properties. Read when visuals should respond to music, voice, or sound.
- **[references/css-patterns.md](references/css-patterns.md)** — CSS+GSAP marker highlighting: highlight, circle, burst, scribble, sketchout. Deterministic, fully seekable. Read when adding visual emphasis to text.
- **[references/typography.md](references/typography.md)** — Typography: font pairing, OpenType features, dark-background adjustments, font discovery script. **Always read** — every composition has text.
- **[references/motion-principles.md](references/motion-principles.md)** — Motion design principles: easing as emotion, timing as weight, choreography as hierarchy, scene pacing, ambient motion, anti-patterns. Read when choreographing GSAP animations.
- **[visual-styles.md](visual-styles.md)** — 8 named visual styles (Swiss Pulse, Velvet Standard, Deconstructed, Maximalist Type, Data Drift, Soft Signal, Folk Frequency, Shadow Cut) with hex palettes, GSAP easing signatures, and shader pairings. Read when user names a style or when generating DESIGN.md.
- **[house-style.md](house-style.md)** — Default motion, sizing, and color palettes when no style is specified.
- **[patterns.md](patterns.md)** — PiP, title cards, slide show patterns.
- **[data-in-motion.md](data-in-motion.md)** — Data, stats, and infographic patterns.
- **[references/transcript-guide.md](references/transcript-guide.md)** — Transcription commands, whisper models, external APIs, troubleshooting.
- **[references/dynamic-techniques.md](references/dynamic-techniques.md)** — Dynamic caption animation techniques (karaoke, clip-path, slam, scatter, elastic, 3D).
- **[references/transitions.md](references/transitions.md)** — Scene transitions: crossfades, wipes, reveals, shader transitions. Energy/mood selection, CSS vs WebGL guidance. **Always read for multi-scene compositions** — scenes without transitions feel like jump cuts.
- [transitions/catalog.md](references/transitions/catalog.md) — Hard rules, scene template, and routing to per-type implementation code.
- Shader transitions are in `@hyperframes/shader-transitions` (`packages/shader-transitions/`) — read package source, not skill files.
GSAP patterns and effects are in the `/gsap` skill.
@@ -0,0 +1,19 @@
# Data in Motion
Light guidance for data and stats in video compositions. The [house style](./house-style.md) handles aesthetics — this just addresses data-specific pitfalls.
## Visual Continuity
When successive stats belong to the same concept (Q1 → Q2 → Q3 → Q4, or three metrics for the same product), keep them in the same visual space with the same aesthetic. Only the VALUE changes. An aesthetic change should signal a new concept, not just a new number.
## Numbers Need Visual Weight
A number on its own floats in empty space. Pair every metric with a visual element that gives it presence — a proportional fill bar, a background color shift, a shape that represents the value, a progress ring. The visual doesn't need to be a chart — it just needs to fill the frame and make the data feel tangible rather than just text on a background.
## Avoid Web Patterns
- **No pie charts** — hard to compare, looks like PowerPoint
- **No multi-axis charts** — viewer can't study intersections in a 3-second window
- **No 6-panel dashboards** — 2-3 related metrics side-by-side is fine, 6+ is a web pattern
- **No gridlines, tick marks, or legends** — visual noise that adds nothing in motion
- **No chart library output** — build with GSAP + SVG/CSS, not D3 or Chart.js
+71
View File
@@ -0,0 +1,71 @@
# House Style
Creative direction for compositions when no `visual-style.md` is provided. These are starting points — override anything that doesn't serve the content.
## Before Writing HTML
1. **Interpret the prompt.** Generate real content. A recipe lists real ingredients. A HUD has real readouts.
2. **Pick a palette.** Light or dark? Declare bg, fg, accent before writing code.
3. **Pick typefaces.** Run the font discovery script in [references/typography.md](references/typography.md) — or pick a font you already know that fits the theme. The script broadens your options; it's not the only source.
## Lazy Defaults to Question
These patterns are AI design tells — the first thing every LLM reaches for. If you're about to use one, pause and ask: is this a deliberate choice for THIS content, or am I defaulting?
- Gradient text (`background-clip: text` + gradient)
- Left-edge accent stripes on cards/callouts
- Cyan-on-dark / purple-to-blue gradients / neon accents
- Pure `#000` or `#fff` (tint toward your accent hue instead)
- Identical card grids (same-size cards repeated)
- Everything centered with equal weight (lead the eye somewhere)
- Banned fonts (see [references/typography.md](references/typography.md) for full list)
If the content genuinely calls for one of these — centered layout for a solemn closing, cards for a real product UI mockup, a banned font because it's the perfect thematic match — use it. The goal is intentionality, not avoidance.
## Color
- Match light/dark to content: food, wellness, kids → light. Tech, cinema, finance → dark.
- One accent hue. Same background across all scenes.
- Tint neutrals toward your accent (even subtle warmth/coolness beats dead gray).
- **Contrast:** enforced by `hyperframes validate` (WCAG AA). Text must be readable with decoratives removed.
- Declare palette up front. Don't invent colors per-element.
## Background Layer
Every scene needs visual depth — persistent decorative elements that stay visible while content animates in. Without these, scenes feel empty during entrance staggering.
Ideas (mix and match, 2-5 per scene):
- Radial glows (accent-tinted, low opacity, breathing scale)
- Ghost text (theme words at 3-8% opacity, very large, slow drift)
- Accent lines (hairline rules, subtle pulse)
- Grain/noise overlay, geometric shapes, grid patterns
- Thematic decoratives (orbit rings for space, vinyl grooves for music, grid lines for data)
All decoratives should have slow ambient GSAP animation — breathing, drift, pulse. Static decoratives feel dead.
## Motion
See [references/motion-principles.md](references/motion-principles.md) for full rules. Quick: 0.30.6s, vary eases, combine transforms on entrances, overlap entries.
## Typography
See [references/typography.md](references/typography.md) for full rules. Quick: 700-900 headlines / 300-400 body, serif + sans (not two sans), 60px+ headlines / 20px+ body.
## Palettes
Declare one background, one foreground, one accent before writing HTML.
| Category | Use for | File |
| ----------------- | --------------------------------------------- | ---------------------------------------------------------- |
| Bold / Energetic | Product launches, social media, announcements | [palettes/bold-energetic.md](palettes/bold-energetic.md) |
| Warm / Editorial | Storytelling, documentaries, case studies | [palettes/warm-editorial.md](palettes/warm-editorial.md) |
| Dark / Premium | Tech, finance, luxury, cinematic | [palettes/dark-premium.md](palettes/dark-premium.md) |
| Clean / Corporate | Explainers, tutorials, presentations | [palettes/clean-corporate.md](palettes/clean-corporate.md) |
| Nature / Earth | Sustainability, outdoor, organic | [palettes/nature-earth.md](palettes/nature-earth.md) |
| Neon / Electric | Gaming, tech, nightlife | [palettes/neon-electric.md](palettes/neon-electric.md) |
| Pastel / Soft | Fashion, beauty, lifestyle, wellness | [palettes/pastel-soft.md](palettes/pastel-soft.md) |
| Jewel / Rich | Luxury, events, sophisticated | [palettes/jewel-rich.md](palettes/jewel-rich.md) |
| Monochrome | Dramatic, typography-focused | [palettes/monochrome.md](palettes/monochrome.md) |
Or derive from OKLCH — pick a hue, build bg/fg/accent at different lightnesses, tint everything toward that hue.
@@ -0,0 +1,14 @@
# Bold / Energetic
Product launches, social media, announcements, high-energy content.
```
#FFBE0B #FB5607 #FF006E #8338EC #3A86FF
#F72585 #7209B7 #3A0CA3 #4361EE #4CC9F0
#EF476F #FFD166 #06D6A0 #118AB2 #073B4C
#FF595E #FFCA3A #8AC926 #1982C4 #6A4C93
#9B5DE5 #F15BB5 #FEE440 #00BBF9 #00F5D4
#390099 #9E0059 #FF0054 #FF5400 #FFBD00
#3D348B #7678ED #F7B801 #F18701 #F35B04
#FFBC42 #D81159 #8F2D56 #218380 #73D2DE
```
@@ -0,0 +1,14 @@
# Clean / Corporate
Explainers, tutorials, presentations, professional content.
```
#FFFCF2 #CCC5B9 #403D39 #252422 #EB5E28
#22223B #4A4E69 #9A8C98 #C9ADA7 #F2E9E4
#3D5A80 #98C1D9 #E0FBFC #EE6C4D #293241
#2B2D42 #8D99AE #EDF2F4 #EF233C #D90429
#353535 #3C6E71 #FFFFFF #D9D9D9 #284B63
#E7ECEF #274C77 #6096BA #A3CEF1 #8B8C89
#CFDBD5 #E8EDDF #F5CB5C #242423 #333533
#2F6690 #3A7CA5 #D9DCD6 #16425B #81C3D7
```
@@ -0,0 +1,14 @@
# Dark / Premium
Tech, finance, luxury, cinematic content.
```
#000000 #14213D #FCA311 #E5E5E5 #FFFFFF
#000814 #001D3D #003566 #FFC300 #FFD60A
#0D1B2A #1B263B #415A77 #778DA9 #E0E1DD
#0D1321 #1D2D44 #3E5C76 #748CAB #F0EBD8
#011627 #FDFFFC #2EC4B6 #E71D36 #FF9F1C
#0B090A #161A1D #660708 #A4161A #E5383B
#001427 #708D81 #F4D58D #BF0603 #8D0801
#001524 #15616D #FFECD1 #FF7D00 #78290F
```
@@ -0,0 +1,14 @@
# Jewel / Rich
Luxury, events, sophisticated, high-end content.
```
#5F0F40 #9A031E #FB8B24 #E36414 #0F4C5C
#780000 #C1121F #FDF0D5 #003049 #669BBC
#10002B #240046 #3C096C #5A189A #7B2CBF
#355070 #6D597A #B56576 #E56B6F #EAAC8B
#6F1D1B #BB9457 #432818 #99582A #FFE6A7
#231942 #5E548E #9F86C0 #BE95C4 #E0B1CB
#461220 #8C2F39 #B23A48 #FCB9B2 #FED0BB
#780116 #F7B538 #DB7C26 #D8572A #C32F27
```
@@ -0,0 +1,14 @@
# Monochrome
Dramatic, typography-focused, serious content.
```
#F8F9FA #E9ECEF #DEE2E6 #CED4DA #ADB5BD #6C757D #495057 #343A40 #212529
#0466C8 #0353A4 #023E7D #002855 #001233
#012A4A #013A63 #01497C #2A6F97 #468FAF #89C2D9
#582F0E #7F4F24 #936639 #A68A64 #C2C5AA
#463F3A #8A817C #BCB8B1 #F4F3EE #E0AFA0
#03071E #370617 #6A040F #9D0208 #DC2F02 #F48C06 #FFBA08
#590D22 #800F2F #A4133C #FF4D6D #FF8FA3 #FFCCD5
#220901 #621708 #941B0C #BC3908 #F6AA1C
```
@@ -0,0 +1,14 @@
# Nature / Earth
Sustainability, outdoor, organic, wellness content.
```
#606C38 #283618 #FEFAE0 #DDA15E #BC6C25
#DAD7CD #A3B18A #588157 #3A5A40 #344E41
#386641 #6A994E #A7C957 #F2E8CF #BC4749
#CAD2C5 #84A98C #52796F #354F52 #2F3E46
#F0EAD2 #DDE5B6 #ADC178 #A98467 #6C584C
#132A13 #31572C #4F772D #90A955 #ECF39E
#6B9080 #A4C3B2 #CCE3DE #EAF4F4 #F6FFF8
#233D4D #FE7F2D #FCCA46 #A1C181 #619B8A
```
@@ -0,0 +1,14 @@
# Neon / Electric
Gaming, tech, nightlife, Gen Z content.
```
#F72585 #B5179E #7209B7 #560BAD #3A0CA3
#70D6FF #FF70A6 #FF9770 #FFD670 #E9FF70
#7400B8 #6930C3 #5E60CE #5390D9 #48BFE3
#0B132B #1C2541 #3A506B #5BC0BE #6FFFE9
#540D6E #EE4266 #FFD23F #3BCEAC #0EAD69
#2D00F7 #6A00F4 #8900F2 #A100F2 #F20089
#FF6D00 #FF7900 #FF8500 #FF9100 #240046
#BBFBFF #8DD8FF #4E71FF #5409DA
```
@@ -0,0 +1,14 @@
# Pastel / Soft
Fashion, beauty, lifestyle, wellness content.
```
#CDB4DB #FFC8DD #FFAFCC #BDE0FE #A2D2FF
#CCD5AE #E9EDC9 #FEFAE0 #FAEDCD #D4A373
#FFD6FF #E7C6FF #C8B6FF #B8C0FF #BBD0FF
#FFA69E #FAF3DD #B8F2E6 #AED9E0 #5E6472
#EDAFB8 #F7E1D7 #DEDBD2 #B0C4B1 #4A5759
#555B6E #89B0AE #BEE3DB #FAF9F9 #FFD6BA
#006D77 #83C5BE #EDF6F9 #FFDDD2 #E29578
#0081A7 #00AFB9 #FDFCDC #FED9B7 #F07167
```
@@ -0,0 +1,14 @@
# Warm / Editorial
Storytelling, documentaries, case studies, narrative content.
```
#264653 #2A9D8F #E9C46A #F4A261 #E76F51
#335C67 #FFF3B0 #E09F3E #9E2A2B #540B0E
#F4F1DE #E07A5F #3D405B #81B29A #F2CC8F
#F6BD60 #F7EDE2 #F5CAC3 #84A59D #F28482
#003049 #D62828 #F77F00 #FCBF49 #EAE2B7
#588B8B #FFFFFF #FFD5C2 #F28F3B #C8553D
#283D3B #197278 #EDDDD4 #C44536 #772E25
#0D3B66 #FAF0CA #F4D35E #EE964B #F95738
```
+118
View File
@@ -0,0 +1,118 @@
# Composition Patterns
## Picture-in-Picture (Video in a Frame)
Animate a wrapper div for position/size. The video fills the wrapper. The wrapper has NO data attributes.
```html
<div
id="pip-frame"
style="position:absolute;top:0;left:0;width:1920px;height:1080px;z-index:50;overflow:hidden;"
>
<video
id="el-video"
data-start="0"
data-duration="60"
data-track-index="0"
src="talking-head.mp4"
muted
playsinline
></video>
</div>
```
```js
tl.to(
"#pip-frame",
{ top: 700, left: 1360, width: 500, height: 280, borderRadius: 16, duration: 1 },
10,
);
tl.to("#pip-frame", { left: 40, duration: 0.6 }, 30);
```
## Title Card with Fade
```html
<div
id="title-card"
data-start="0"
data-duration="5"
data-track-index="5"
style="display:flex;align-items:center;justify-content:center;background:#111;z-index:60;"
>
<h1 style="font-size:64px;color:#fff;opacity:0;">My Video Title</h1>
</div>
```
```js
tl.to("#title-card h1", { opacity: 1, duration: 0.6 }, 0.3);
tl.to("#title-card", { opacity: 0, duration: 0.5 }, 4);
```
## Slide Show with Section Headers
Use separate elements on the same track, each with its own time range. Slides auto-mount/unmount based on `data-start`/`data-duration`.
```html
<div class="slide" data-start="0" data-duration="30" data-track-index="3">...</div>
<div class="slide" data-start="30" data-duration="25" data-track-index="3">...</div>
<div class="slide" data-start="55" data-duration="20" data-track-index="3">...</div>
```
## Top-Level Composition Example
```html
<div
id="comp-1"
data-composition-id="my-video"
data-start="0"
data-duration="60"
data-width="1920"
data-height="1080"
>
<!-- Primitive clips -->
<video
id="el-1"
data-start="0"
data-duration="10"
data-track-index="0"
src="..."
muted
playsinline
></video>
<video
id="el-2"
data-start="el-1"
data-duration="8"
data-track-index="0"
src="..."
muted
playsinline
></video>
<img id="el-3" data-start="5" data-duration="4" data-track-index="1" src="..." />
<audio id="el-4" data-start="0" data-duration="30" data-track-index="2" src="..." />
<!-- Sub-compositions loaded from files -->
<div
id="el-5"
data-composition-id="intro-anim"
data-composition-src="compositions/intro-anim.html"
data-start="0"
data-track-index="3"
></div>
<div
id="el-6"
data-composition-id="captions"
data-composition-src="compositions/caption-overlay.html"
data-start="0"
data-track-index="4"
></div>
<script>
// Just register the timeline — framework auto-nests sub-compositions
const tl = gsap.timeline({ paused: true });
window.__timelines["my-video"] = tl;
</script>
</div>
```
@@ -0,0 +1,76 @@
# Audio-Reactive Animation
Drive visuals from music, voice, or sound. Any GSAP-animatable property can respond to pre-extracted audio data.
## Audio Data Format
```js
var AUDIO_DATA = {
fps: 30,
totalFrames: 900,
frames: [{ bands: [0.82, 0.45, 0.31, ...] }, ...]
};
```
- `frames[i].bands[]` — frequency band amplitudes, 0-1. Index 0 = bass, higher = treble.
- Each band normalized independently across the full track.
## Mapping Audio to Visuals
| Audio signal | Visual property | Effect |
| ---------------------- | --------------------------------- | -------------------------- |
| Bass (bands[0]) | `scale` | Pulse on beat |
| Treble (bands[12-14]) | `textShadow`, `boxShadow` | Glow intensity |
| Overall amplitude | `opacity`, `y`, `backgroundColor` | Breathe, lift, color shift |
| Mid-range (bands[4-8]) | `borderRadius`, `width` | Shape morphing |
Any GSAP-tweenable property works — `clipPath`, `filter`, SVG attributes, CSS custom properties.
## Content, Not Medium
Audio provides **timing and intensity**. The visual vocabulary comes from the narrative.
**Never add:** equalizer bars, spectrum analyzers, waveform displays, musical notes clip art, generic particle systems, rainbow color cycling, strobing white on beats, abstract pulsing orbs.
**Instead:** Let content guide the visual and audio drive its behavior. Bass makes warmth _swell_. Treble sharpens _contrast_. The visual choice comes from "what does this piece feel like?"
## Sampling Pattern
Audio reactivity requires per-frame sampling via a `for` loop with `tl.call()`, not a single tween:
```js
// ✅ Correct — sample every frame
for (var f = 0; f < AUDIO_DATA.totalFrames; f++) {
tl.call(
(function (frame) {
return function () {
draw(frame);
};
})(AUDIO_DATA.frames[f]),
[],
f / AUDIO_DATA.fps,
);
}
// ❌ Wrong — single tween, doesn't react to audio
gsap.to(".el", { scale: 1.2, duration: totalDuration });
```
Without per-frame sampling, the composition doesn't actually react to audio.
## textShadow Gotcha
`textShadow` on a parent container with semi-transparent children (e.g., inactive caption words at `rgba(255,255,255,0.3)`) renders a visible glow rectangle behind all children. Fix: apply `scale` to the container for beat pulse, but apply `textShadow` to individual active words only.
## Guidelines
- **Subtlety for text** — 3-6% scale variation, soft glow. Heavy pulsing makes text unreadable.
- **Go bigger on non-text** — backgrounds and shapes can handle 10-30% swings.
- **Match the energy** — corporate = subtle; music video = dramatic.
- **Deterministic** — pre-extracted data, no Web Audio API, no runtime analysis.
## Constraints
- All audio data must be pre-extracted (use `extract-audio-data.py` from the gsap skill's scripts/)
- No `Math.random()` or `Date.now()`
- Audio reactivity runs on the same GSAP timeline as everything else
@@ -0,0 +1,132 @@
# Captions
## Language Rule (Non-Negotiable)
**Never use `.en` models unless the user explicitly states the audio is English.** `.en` models TRANSLATE non-English audio into English instead of transcribing it.
1. User says the language → `--model small --language <code>` (no `.en`)
2. User says English → `--model small.en`
3. Language unknown → `--model small` (no `.en`, no `--language`) — auto-detects
---
Analyze spoken content to determine caption style. If user specifies a style, use that. Otherwise, detect tone from the transcript.
## Transcript Source
```json
[
{ "text": "Hello", "start": 0.0, "end": 0.5 },
{ "text": "world.", "start": 0.6, "end": 1.2 }
]
```
For transcription commands, whisper models, external APIs, see [transcript-guide.md](transcript-guide.md).
## Style Detection (When No Style Specified)
Read the full transcript before choosing. Four dimensions:
**1. Visual feel** — corporate→clean; energetic→bold; storytelling→elegant; technical→precise; social→playful.
**2. Color palette** — dark+bright for energy; muted for professional; high contrast for clarity; one accent color.
**3. Font mood** — heavy/condensed for impact; clean sans for modern; rounded for friendly; serif for elegance.
**4. Animation character** — scale-pop for punchy; gentle fade for calm; word-by-word for emphasis; typewriter for technical.
## Per-Word Styling
Scan for words deserving distinct treatment:
- **Brand/product names** — larger size, unique color
- **ALL CAPS** — scale boost, flash, accent color
- **Numbers/statistics** — bold weight, accent color
- **Emotional keywords** — exaggerated animation (overshoot, bounce)
- **Call-to-action** — highlight, underline, color pop
- **Marker highlight** — for beyond-color emphasis, see [css-patterns.md](css-patterns.md)
## Script-to-Style Mapping
| Tone | Font mood | Animation | Color | Size |
| ------------ | ------------------------ | ---------------------------------- | --------------------------- | ------- |
| Hype/launch | Heavy condensed, 800-900 | Scale-pop, back.out(1.7), 0.1-0.2s | Bright on dark | 72-96px |
| Corporate | Clean sans, 600-700 | Fade+slide, power3.out, 0.3s | White/neutral, muted accent | 56-72px |
| Tutorial | Mono/clean sans, 500-600 | Typewriter/fade, 0.4-0.5s | High contrast, minimal | 48-64px |
| Storytelling | Serif/elegant, 400-500 | Slow fade, power2.out, 0.5-0.6s | Warm muted tones | 44-56px |
| Social | Rounded sans, 700-800 | Bounce, elastic.out, word-by-word | Playful, colored pills | 56-80px |
## Word Grouping
- **High energy:** 2-3 words. Quick turnover.
- **Conversational:** 3-5 words. Natural phrases.
- **Measured/calm:** 4-6 words. Longer groups.
Break on sentence boundaries, 150ms+ pauses, or max word count.
## Positioning
- **Landscape (1920x1080):** Bottom 80-120px, centered
- **Portrait (1080x1920):** Lower middle ~600-700px from bottom, centered
- Never cover the subject's face
- `position: absolute` — never relative
- One caption group visible at a time
## Text Overflow Prevention
Use `window.__hyperframes.fitTextFontSize()`:
```js
var result = window.__hyperframes.fitTextFontSize(group.text.toUpperCase(), {
fontFamily: "Outfit",
fontWeight: 900,
maxWidth: 1600,
});
el.style.fontSize = result.fontSize + "px";
```
Options: `maxWidth` (1600 landscape, 900 portrait), `baseFontSize` (78), `minFontSize` (42), `fontWeight`, `fontFamily`, `step` (2).
CSS safety nets: `max-width` on container, `overflow: visible` (**not** `hidden` — hidden clips scaled emphasis words and glow effects), `position: absolute`, explicit `height`. When per-word styling uses `scale > 1.0`, compute `maxWidth = safeWidth / maxScale` to leave headroom.
**Container pattern:** Full-width absolute container, centered. Do **not** use `left: 50%; transform: translateX(-50%)` — causes clipping at composition edges.
## Caption Exit Guarantee
Every group **must** have a hard kill after exit animation:
```js
tl.to(groupEl, { opacity: 0, scale: 0.95, duration: 0.12, ease: "power2.in" }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end); // deterministic kill
```
Self-lint after building timeline — place **before** `window.__timelines[id] = tl` so it runs at composition init:
```js
GROUPS.forEach(function (group, gi) {
var el = document.getElementById("cg-" + gi);
if (!el) return;
tl.seek(group.end + 0.01);
var computed = window.getComputedStyle(el);
if (computed.opacity !== "0" && computed.visibility !== "hidden") {
console.warn(
"[caption-lint] group " + gi + " still visible at t=" + (group.end + 0.01).toFixed(2) + "s",
);
}
});
tl.seek(0);
```
## Further References
- [dynamic-techniques.md](dynamic-techniques.md) — karaoke, clip-path reveals, slam words, scatter exits, elastic, 3D rotation
- [transcript-guide.md](transcript-guide.md) — transcription commands, whisper models, external APIs
- [css-patterns.md](css-patterns.md) — CSS+GSAP marker highlighting (deterministic, fully seekable)
## Constraints
- Deterministic. No `Math.random()`, no `Date.now()`.
- Sync to transcript timestamps.
- One group visible at a time.
- Every group must have a hard `tl.set` kill at `group.end`.
- The compiler embeds supported fonts automatically — just declare `font-family` in CSS.
@@ -0,0 +1,373 @@
# CSS Patterns for Marker Highlighting
Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes. Use these for deterministic rendering in HyperFrames compositions — no external library dependency, full GSAP timeline control.
## Table of Contents
- [1. Highlight Mode](#1-highlight-mode) — Yellow marker sweep behind text
- [2. Circle Mode](#2-circle-mode) — Hand-drawn ellipse around text
- [3. Burst Mode](#3-burst-mode) — Radiating lines from text
- [4. Scribble Mode](#4-scribble-mode) — Chaotic scribble over text
- [5. Sketchout Mode](#5-sketchout-mode) — Rough rectangle outline
## 1. Highlight Mode
Yellow marker sweep behind text. The most common mode.
```html
<span class="mh-highlight-wrap">
<span class="mh-highlight-bar" id="hl-1"></span>
<span class="mh-highlight-text">highlighted text</span>
</span>
```
```css
.mh-highlight-wrap {
position: relative;
display: inline;
}
.mh-highlight-bar {
position: absolute;
top: 0;
left: -6px;
right: -6px;
bottom: 0;
background: #fdd835;
opacity: 0.35;
transform: scaleX(0);
transform-origin: left center;
border-radius: 3px;
z-index: 0;
}
.mh-highlight-text {
position: relative;
z-index: 1;
}
```
```js
// Sweep in from left
tl.to("#hl-1", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.6);
// Optional: skew for hand-drawn feel
// gsap.set("#hl-1", { skewX: -2 });
```
### Multi-line Highlight
Stagger bars across multiple lines:
```js
tl.to(
".mh-highlight-bar",
{
scaleX: 1,
duration: 0.5,
ease: "power2.out",
stagger: 0.3,
},
0.6,
);
```
## 2. Circle Mode
Hand-drawn circle around text. Use `border-radius: 50%` with a slight rotation for organic feel.
```html
<span class="mh-circle-wrap">
<span class="mh-circle-text" id="circle-word">IMPORTANT</span>
<span class="mh-circle-ring" id="circle-1"></span>
</span>
```
```css
.mh-circle-wrap {
position: relative;
display: inline;
}
.mh-circle-text {
position: relative;
z-index: 1;
}
.mh-circle-ring {
position: absolute;
top: 50%;
left: 50%;
width: 130%;
height: 160%;
transform: translate(-50%, -50%) rotate(-3deg) scale(0);
border: 3px solid #e53935;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
```
```js
// Circle scales in with a wobble
tl.to(
"#circle-1",
{
scale: 1,
rotation: -3,
duration: 0.6,
ease: "back.out(1.7)",
transformOrigin: "center center",
},
0.7,
);
```
### Variations
```css
/* Tighter circle (for short words) */
.mh-circle-ring.tight {
width: 150%;
height: 180%;
}
/* Squared circle (rounded rectangle) */
.mh-circle-ring.rounded {
border-radius: 30%;
width: 120%;
height: 140%;
}
/* Ellipse (wider than tall) */
.mh-circle-ring.ellipse {
width: 150%;
height: 130%;
border-radius: 50%;
}
```
## 3. Burst Mode
Radiating lines from text center. Each line is a positioned div rotated to its angle.
```html
<span class="mh-burst-wrap">
<span class="mh-burst-text">WOW</span>
<span class="mh-burst-container" id="burst-1">
<span class="mh-burst-line" style="--angle: 0deg; --len: 70px;"></span>
<span class="mh-burst-line" style="--angle: 30deg; --len: 55px;"></span>
<span class="mh-burst-line" style="--angle: 60deg; --len: 80px;"></span>
<span class="mh-burst-line" style="--angle: 90deg; --len: 45px;"></span>
<span class="mh-burst-line" style="--angle: 120deg; --len: 65px;"></span>
<span class="mh-burst-line" style="--angle: 150deg; --len: 75px;"></span>
<span class="mh-burst-line" style="--angle: 180deg; --len: 50px;"></span>
<span class="mh-burst-line" style="--angle: 210deg; --len: 60px;"></span>
<span class="mh-burst-line" style="--angle: 240deg; --len: 80px;"></span>
<span class="mh-burst-line" style="--angle: 270deg; --len: 40px;"></span>
<span class="mh-burst-line" style="--angle: 300deg; --len: 70px;"></span>
<span class="mh-burst-line" style="--angle: 330deg; --len: 55px;"></span>
</span>
</span>
```
```css
.mh-burst-wrap {
position: relative;
display: inline;
}
.mh-burst-text {
position: relative;
z-index: 2;
}
.mh-burst-container {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
z-index: 1;
}
.mh-burst-line {
position: absolute;
display: block;
width: 3px;
height: var(--len);
background: #1e88e5;
left: -1.5px;
top: calc(-1 * var(--len));
transform: rotate(var(--angle));
transform-origin: bottom center;
opacity: 0;
}
```
```js
// All lines burst outward simultaneously with slight stagger
tl.fromTo(
"#burst-1 .mh-burst-line",
{ scaleY: 0, opacity: 0 },
{ scaleY: 1, opacity: 1, duration: 0.4, ease: "power2.out", stagger: 0.03 },
0.7,
);
```
**Vary line lengths** (40-80px range) for an organic, hand-drawn feel. Equal lengths look mechanical.
## 4. Scribble Mode
Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffset`.
```html
<span class="mh-scribble-wrap">
<span class="mh-scribble-text">underlined text</span>
<svg class="mh-scribble-svg" viewBox="0 0 500 24" preserveAspectRatio="none">
<path
id="scribble-1"
d="M0,12 Q31,0 62,12 Q93,24 125,12 Q156,0 187,12 Q218,24 250,12 Q281,0 312,12 Q343,24 375,12 Q406,0 437,12 Q468,24 500,12"
fill="none"
stroke="#FDD835"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</div>
```
```css
.mh-scribble-wrap {
position: relative;
display: inline;
}
.mh-scribble-text {
position: relative;
z-index: 1;
}
.mh-scribble-svg {
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 24px;
z-index: 0;
}
```
```js
// Measure path length and set initial dash state
var path = document.querySelector("#scribble-1");
var len = path.getTotalLength();
gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
// Draw the line
tl.to(
"#scribble-1",
{
strokeDashoffset: 0,
duration: 0.8,
ease: "power1.inOut",
},
0.7,
);
```
### Strikethrough Variant
Position the SVG at `top: 50%; transform: translateY(-50%)` instead of `bottom: -6px`.
### Wavy Path Generator
Scale the path's viewBox width to match text width. The wave pattern `Q x1,y1 x2,y2` alternates between `y=0` and `y=24` for a natural wobble. Adjust the control points for tighter or looser waves:
- **Tight waves**: smaller x-increments (25px per half-wave)
- **Loose waves**: larger x-increments (50px per half-wave)
- **Amplitude**: change the y range (0-24 for standard, 0-16 for subtle)
## 5. Sketchout Mode
Cross-hatch lines over de-emphasized text. Multiple angled lines create a "crossed out" effect.
```html
<span class="mh-sketchout-wrap">
<span class="mh-sketchout-text">old price</span>
<span class="mh-sketchout-lines" id="sketchout-1">
<span class="mh-sketchout-line mh-sketchout-fwd"></span>
<span class="mh-sketchout-line mh-sketchout-bwd"></span>
</span>
</span>
```
```css
.mh-sketchout-wrap {
position: relative;
display: inline;
}
.mh-sketchout-text {
position: relative;
z-index: 0;
}
.mh-sketchout-lines {
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
overflow: hidden;
z-index: 1;
}
.mh-sketchout-line {
position: absolute;
display: block;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #e53935;
transform-origin: left center;
transform: scaleX(0);
}
.mh-sketchout-fwd {
transform: scaleX(0) rotate(-12deg);
}
.mh-sketchout-bwd {
transform: scaleX(0) rotate(12deg);
}
```
```js
// Forward slash draws first
tl.to(
"#sketchout-1 .mh-sketchout-fwd",
{
scaleX: 1,
duration: 0.3,
ease: "power2.out",
},
1.0,
);
// Backward slash follows
tl.to(
"#sketchout-1 .mh-sketchout-bwd",
{
scaleX: 1,
duration: 0.3,
ease: "power2.out",
},
1.15,
);
```
## Combining Modes in Captions
Use mode cycling for visual variety across caption groups:
```js
var MODES = ["highlight", "circle", "burst", "scribble"];
GROUPS.forEach(function (group, gi) {
var mode = MODES[gi % MODES.length];
// Apply the mode's CSS pattern to emphasis words in this group
group.emphasisWords.forEach(function (word) {
applyMode(word.el, mode, tl, word.start);
});
});
```
Cycle every 2-3 groups for high energy, every 3-4 for medium, every 4-5 for low.
@@ -0,0 +1,90 @@
# Dynamic Caption Techniques
You are here because SKILL.md told you to read this file before writing animation code. Pick your technique combination from the table below based on the energy level you detected from the transcript, then implement using standard GSAP patterns.
## Technique Selection by Energy
| Energy level | Highlight | Exit | Cycle pattern |
| ------------ | ------------------------------------- | ------------------- | ----------------------------------------- |
| High | Karaoke with accent glow + scale pop | Scatter or drop | Alternate highlight styles every 2 groups |
| Medium-high | Karaoke with color pop | Scatter or collapse | Alternate every 3 groups |
| Medium | Karaoke (subtle, white only) | Fade + slide | Alternate every 3 groups |
| Medium-low | Karaoke (minimal scale change) | Fade | Single style, vary ease per group |
| Low | Karaoke (warm tones, slow transition) | Collapse | Alternate every 4 groups |
**All energy levels use karaoke highlight as the baseline.** The difference is intensity — high energy gets accent color + glow + 15% scale pop on active words, low energy gets a gentle white shift with 3% scale.
**Emphasis words always break the pattern.** When a word is flagged as emphasis (emotional keyword, ALL CAPS, brand name), give it a stronger animation than surrounding words (larger scale, accent color, overshoot ease). This creates contrast.
**Marker highlight modes add a visual layer on top of karaoke.** For emphasis words that need more than color/scale, add a marker-style effect — highlight sweep, circle, burst, or scribble — using the `/marker-highlight` skill. Match mode to energy: burst for hype, circle for key terms, highlight for standard, scribble for subtle.
## Audio-Reactive Captions (Mandatory for Music)
**If the source audio is music (vocals over instrumentation, beats, any musical content), you MUST extract audio data and add audio-reactive animations.** This is not optional — music without audio reactivity looks disconnected. Even low-energy ballads get subtle bass pulse and treble glow.
No special wiring is needed. The group loop already iterates over every caption group to build entrance, karaoke, and exit tweens. At that point, read the audio data for each group's time range and use it to modulate the group's animation intensity with regular GSAP tweens.
```js
// Load audio data inline (same pattern as TRANSCRIPT)
var AUDIO = JSON.parse(audioDataJson); // { fps, totalFrames, frames: [{ bands: [...] }] }
GROUPS.forEach(function (group, gi) {
var groupEl = document.getElementById("cg-" + gi);
if (!groupEl) return;
// Read peak energy for this group's time range
var startFrame = Math.floor(group.start * AUDIO.fps);
var endFrame = Math.min(Math.floor(group.end * AUDIO.fps), AUDIO.totalFrames - 1);
var peakBass = 0;
var peakTreble = 0;
for (var f = startFrame; f <= endFrame; f++) {
var frame = AUDIO.frames[f];
if (!frame) continue;
peakBass = Math.max(peakBass, frame.bands[0] || 0, frame.bands[1] || 0);
peakTreble = Math.max(peakTreble, frame.bands[6] || 0, frame.bands[7] || 0);
}
// Modulate entrance — louder groups enter bigger and glowier
tl.to(
groupEl,
{
scale: 1 + peakBass * 0.06,
textShadow:
"0 0 " + Math.round(peakTreble * 12) + "px rgba(255,255,255," + peakTreble * 0.4 + ")",
duration: 0.3,
ease: "power2.out",
},
group.start,
);
// Reset at exit so audio-driven values don't persist
tl.set(groupEl, { scale: 1, textShadow: "none" }, group.end - 0.15);
});
```
This shapes the animation at build time, not playback time — no per-frame callbacks, no `tl.call()` loops, no async fetch timing issues. Loud groups come in with more weight and glow; quiet groups come in soft. The audio data modulates _how much_, the content determines _what_.
Keep audio reactivity subtle — 3-6% scale variation and soft glow. Heavy pulsing makes text unreadable.
To generate the audio data file:
```bash
python3 skills/gsap-effects/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json
```
## Combining Techniques
Don't use the same highlight animation on every group — cycle through styles using the group index. Don't combine multiple competing animations on the same word at the same timestamp. Vary techniques across groups to match the content's pace changes.
**Marker highlight effects** (from the `/marker-highlight` skill) layer well with karaoke — use karaoke for the word-by-word reveal, then add a marker effect on emphasis words only. For example: karaoke highlights each word in white, but brand names get a yellow highlight sweep and stats get a red circle. Cycle marker modes across groups for visual variety (see the mode-to-energy mapping in the marker-highlight skill).
## Available Tools
These tools are available in the HyperFrames runtime. Use them when they solve a real problem — not every composition needs all of them.
| Tool | What it does | Access | When it's useful |
| ------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| **pretext** | Pure-arithmetic text measurement without DOM reflow. 0.0002ms per call. | `window.__hyperframes.pretext.prepare(text, font)` / `.layout(prepared, maxWidth, lineHeight)` | Per-frame text reflow, shrinkwrap containers, computing layout before render |
| **fitTextFontSize** | Finds the largest font size that fits text on one line. Built on pretext. | `window.__hyperframes.fitTextFontSize(text, { maxWidth, fontFamily, fontWeight })` | Overflow prevention for long phrases, portrait mode, large base sizes |
| **audio data** | Pre-extracted per-frame RMS energy and frequency bands. | Extract with `extract-audio-data.py`, load inline or via `fetch("audio-data.json")` | Audio-reactive visuals — modulate intensity based on the music |
| **GSAP** | Animation timeline with tweens and callbacks. | `gsap.to()`, `gsap.set()`, `tl.to()`, `tl.set()` | All caption animation |
@@ -0,0 +1,69 @@
# Motion Principles
## Guardrails
You know these rules but you violate them. Stop.
- **Don't use the same ease on every tween.** You default to `power2.out` on everything. Vary eases like you vary font weights — no more than 2 independent tweens with the same ease in a scene.
- **Don't use the same speed on everything.** You default to 0.4-0.5s for everything. The slowest scene should be 3× slower than the fastest. Vary duration deliberately.
- **Don't enter everything from the same direction.** You default to `y: 30, opacity: 0` on every element. Vary: from left, from right, from scale, opacity-only, letter-spacing.
- **Don't use the same stagger on every scene.** Each scene needs its own rhythm.
- **Don't use ambient zoom on every scene.** Pick different ambient motion per scene: slow pan, subtle rotation, scale push, color shift, or nothing. Stillness after motion is powerful.
- **Don't start at t=0.** Offset the first animation 0.1-0.3s. Zero-delay feels like a jump cut.
## What You Don't Do Without Being Told
### Easing is emotion, not technique
The transition is the verb. The easing is the adverb. A slide-in with `expo.out` = confident. With `sine.inOut` = dreamy. With `elastic.out` = playful. Same motion, different meaning. Choose the adverb deliberately.
**Direction rules — these are not optional:**
- `.out` for elements entering. Starts fast, decelerates. Feels responsive. This is your default.
- `.in` for elements leaving. Starts slow, accelerates away. Throws them off.
- `.inOut` for elements moving between positions.
You get this backwards constantly. Ease-in for entrances feels sluggish. Ease-out for exits feels reluctant.
### Speed communicates weight
- Fast (0.15-0.3s) — energy, urgency, confidence
- Medium (0.3-0.5s) — professional, most content
- Slow (0.5-0.8s) — gravity, luxury, contemplation
- Very slow (0.8-2.0s) — cinematic, emotional, atmospheric
### Scene structure: build / breathe / resolve
Every scene has three phases. You dump everything in the build and leave nothing for breathe or resolve.
- **Build (0-30%)** — elements enter, staggered. Don't dump everything at once.
- **Breathe (30-70%)** — content visible, alive with ONE ambient motion.
- **Resolve (70-100%)** — exit or decisive end. Exits are faster than entrances.
### Transitions are meaning
- **Crossfade** = "this continues"
- **Hard cut** = "wake up" / disruption
- **Slow dissolve** = "drift with me"
You crossfade everything. Use hard cuts for disruption and register shifts.
### Choreography is hierarchy
The element that moves first is perceived as most important. Stagger in order of importance, not DOM order. Don't wait for completion — overlap entries. Total stagger sequence under 500ms regardless of item count.
### Asymmetry
Entrances need longer than exits. A card takes 0.4s to appear but 0.25s to disappear.
## Visual Composition
You build for the web. Video frames are not pages.
- **Two focal points minimum per scene.** The eye needs somewhere to travel. Never a single text block floating in empty space.
- **Fill the frame.** Hero text: 60-80% of width. You will try to use web-sized elements. Don't.
- **Three layers minimum per scene.** Background treatment (glow, oversized faded type, color panel). Foreground content. Accent elements (dividers, labels, data bars).
- **Background is not empty.** Radial glows, oversized faded type bleeding off-frame, subtle border panels, hairline rules. Pure solid #000 reads as "nothing loaded."
- **Anchor to edges.** Pin content to left/top or right/bottom. Centered-and-floating is a web pattern.
- **Split frames.** Data panel on the left, content on the right. Top bar with metadata, full-width below. Zone-based layouts, not centered stacks.
- **Use structural elements.** Rules, dividers, border panels. They create paths for the eye and animate well (scaleX from 0).
@@ -0,0 +1,151 @@
# Transcript Guide
## How Transcripts Are Generated
`hyperframes transcribe` handles both transcription and format conversion:
```bash
# Transcribe audio/video (uses whisper.cpp locally, no API key needed)
npx hyperframes transcribe audio.mp3
# Use a larger model for better accuracy
npx hyperframes transcribe audio.mp3 --model medium.en
# Filter to English only (skips non-English speech)
npx hyperframes transcribe audio.mp3 --language en
# Import an existing transcript from another tool
npx hyperframes transcribe captions.srt
npx hyperframes transcribe captions.vtt
npx hyperframes transcribe openai-response.json
```
## Supported Input Formats
The CLI auto-detects and normalizes these formats:
| Format | Extension | Source | Word-level? |
| --------------------- | --------- | --------------------------------------------------------------------------- | ----------------- |
| whisper.cpp JSON | `.json` | `hyperframes init --video`, `hyperframes transcribe` | Yes |
| OpenAI Whisper API | `.json` | `openai.audio.transcriptions.create({ timestamp_granularities: ["word"] })` | Yes |
| SRT subtitles | `.srt` | Video editors, subtitle tools, YouTube | No (phrase-level) |
| VTT subtitles | `.vtt` | Web players, YouTube, transcription services | No (phrase-level) |
| Normalized word array | `.json` | Pre-processed by any tool | Yes |
**Word-level timestamps produce better captions.** SRT/VTT give phrase-level timing, which works but can't do per-word animation effects.
## Whisper Model Guide
The default model (`small.en`) balances accuracy and speed. For better results, use a larger model:
| Model | Size | Speed | Accuracy | When to use |
| ---------- | ------ | -------- | --------- | ------------------------------------- |
| `tiny` | 75 MB | Fastest | Low | Quick previews, testing pipeline |
| `base` | 142 MB | Fast | Fair | Short clips, clear audio |
| `small` | 466 MB | Moderate | Good | **Default** — good for most content |
| `medium` | 1.5 GB | Slow | Very good | Important content, noisy audio, music |
| `large-v3` | 3.1 GB | Slowest | Best | Production quality |
**Only add `.en` suffix when the user explicitly says the audio is English.** `.en` models are slightly more accurate for English but will TRANSLATE non-English audio instead of transcribing it.
**Critical: `.en` models translate non-English audio into English** — they don't transcribe it. If the audio might not be English, always use a model without the `.en` suffix and pass `--language` to specify the source language. If you're unsure of the language, use `small` (not `small.en`) without `--language` — whisper will auto-detect.
```bash
# Spanish audio
npx hyperframes transcribe audio.mp3 --model small --language es
# Unknown language — let whisper auto-detect
npx hyperframes transcribe audio.mp3 --model small
```
**Music and vocals over instrumentation**: `small.en` will misidentify lyrics — use `medium.en` as the minimum, or import lyrics manually. Even `medium.en` struggles with heavily produced tracks; for music videos, providing known lyrics as an SRT/VTT and importing with `hyperframes transcribe lyrics.srt` will always beat automated transcription.
## Transcript Quality Check (Mandatory)
After every transcription, **read the transcript and check for quality issues before proceeding.** Bad transcripts produce nonsensical captions. Never skip this step.
### What to look for
| Signal | Example | Cause |
| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------- |
| Music note tokens (`♪`, ``) | `{ "text": "♪" }` or `{ "text": "" }` | Whisper detected music, not speech |
| Garbled / nonsense words | "Do a chin", "Get so gay", "huh" | Model misheard lyrics or background noise |
| Long gaps with no words | 20+ seconds of only `♪` tokens | Instrumental section — expected, but high ratio means speech is being missed |
| Repeated filler | Many "huh", "uh", "oh" entries | Model is hallucinating on music |
| Very short word spans | Words with `end - start < 0.05` | Unreliable timestamp alignment |
### Automatic retry rules
**If more than 20% of entries are `♪`/`` tokens, or the transcript contains obvious nonsense words, the transcription failed.** Do not proceed with the bad transcript. Instead:
1. **Retry with `medium.en`** if the original used `small.en` or smaller:
```bash
npx hyperframes transcribe audio.mp3 --model medium.en
```
2. **If `medium.en` also fails** (still >20% music tokens or garbled), tell the user the audio is too noisy for local transcription and suggest:
- Providing lyrics manually as an SRT/VTT file
- Using an external API (OpenAI or Groq Whisper — see below)
3. **Always clean the transcript** before building captions — filter out ``/`` tokens and entries where `text` is a single non-word character. Only real words should reach the caption composition.
### Cleaning a transcript
After transcription (even with a good model), strip non-word entries:
```js
var raw = JSON.parse(transcriptJson);
var words = raw.filter(function (w) {
if (!w.text || w.text.trim().length === 0) return false;
if (/^[♪\u266a\u266b\u266c\u266d\u266e\u266f]+$/.test(w.text)) return false;
if (/^(huh|uh|um|ah|oh)$/i.test(w.text) && w.end - w.start < 0.1) return false;
return true;
});
```
### When to use which model (decision tree)
1. **Is this speech over silence/light background?** → `small.en` is fine
2. **Is this speech over music, or music with vocals?** → Start with `medium.en`
3. **Is this a produced music track (vocals + full instrumentation)?** → Start with `medium.en`, expect to need manual lyrics or an external API
4. **Is this multilingual?** → Use `medium` or `large-v3` (no `.en` suffix)
## Using External Transcription APIs
For the best accuracy, use an external API and import the result:
**OpenAI Whisper API** (recommended for quality):
```bash
# Generate with word timestamps, then import
curl https://api.openai.com/v1/audio/transcriptions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-F file=@audio.mp3 -F model=whisper-1 \
-F response_format=verbose_json \
-F "timestamp_granularities[]=word" \
-o transcript-openai.json
npx hyperframes transcribe transcript-openai.json
```
**Groq Whisper API** (fast, free tier available):
```bash
curl https://api.groq.com/openai/v1/audio/transcriptions \
-H "Authorization: Bearer $GROQ_API_KEY" \
-F file=@audio.mp3 -F model=whisper-large-v3 \
-F response_format=verbose_json \
-F "timestamp_granularities[]=word" \
-o transcript-groq.json
npx hyperframes transcribe transcript-groq.json
```
## If No Transcript Exists
1. Check the project root for `transcript.json`, `.srt`, or `.vtt` files
2. If none found, run transcription — pick the starting model based on the content type:
- Speech/voiceover → `small.en`
- Music with vocals → `medium.en`
```bash
npx hyperframes transcribe <audio-or-video-file> --model medium.en
```
3. **Read the transcript and run the quality check** (see above). If it fails, retry with a larger model or suggest manual lyrics.
@@ -0,0 +1,112 @@
# Scene Transitions
A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically.
## Animation Rules for Multi-Scene Compositions
These are non-negotiable for every multi-scene composition:
1. **Every composition uses transitions.** No exceptions. Scenes without transitions feel like jump cuts.
2. **Every scene uses entrance animations.** Elements animate IN via `gsap.from()` — opacity, position, scale, etc. No scene should pop fully-formed onto screen.
3. **Exit animations are BANNED** except on the final scene. Do NOT use `gsap.to()` to animate elements out before a transition fires. The transition IS the exit. Outgoing scene content must be fully visible when the transition starts — the transition handles the visual handoff.
4. **Final scene exception:** The last scene MAY fade elements out (e.g., fade to black at the end of the composition). This is the only scene where exit animations are allowed.
## Energy → Primary Transition
| Energy | CSS Primary | Shader Primary | Accent | Duration | Easing |
| ---------------------------------------- | ---------------------------- | ------------------------------------ | ------------------------------ | --------- | ---------------------- |
| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Cross-warp morph, thermal distortion | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` |
| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Whip pan, cinematic zoom | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` |
| **High** (promos, sports, music, launch) | Zoom through, overexposure | Ridged burn, glitch, chromatic split | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` |
Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different transition for every scene.
## Mood → Transition Type
Think about what the transition _communicates_, not just what it looks like.
| Mood | Transitions | Why it works |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn · **Shader:** thermal distortion, light leak, cross-warp morph | Soft edges, warm color washes. Nothing sharp or mechanical. |
| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve · **Shader:** gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. |
| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter · **Shader:** whip pan | Like turning a page or slicing a layout. Clean directional movement. |
| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration · **Shader:** glitch, chromatic split | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. |
| **Tense / edgy** | Glitch, VHS, chromatic aberration, ripple · **Shader:** ridged burn, glitch, domain warp | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. |
| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe · **Shader:** ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. |
| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, color dip to black · **Shader:** cinematic zoom, gravitational lens, domain warp | Scale, weight, light extremes. Shader transitions add per-pixel depth. |
| **Premium / luxury** | Focus pull, blur crossfade, color dip to black · **Shader:** cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. |
| **Retro / analog** | Film burn, light leak, VHS, clock wipe · **Shader:** light leak | Organic imperfection. Warm color bleeds, scan line displacement. |
## Narrative Position
| Position | Use | Why |
| -------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |
| **Opening** | Your most distinctive transition. Match the mood. 0.4-0.6s | Sets the visual language for the entire piece. |
| **Between related points** | Your primary transition. Consistent. 0.3s | Don't distract — the content is continuing. |
| **Topic change** | Something different from your primary. Staggered blocks, shutter, squeeze. | Signals "new section" — the viewer's brain resets. |
| **Climax / hero reveal** | Your boldest accent. Fastest or most dramatic. | This is the payoff — spend your best transition here. |
| **Wind-down** | Return to gentle. Blur crossfade, crossfade. 0.5-0.7s | Let the viewer exhale after the climax. |
| **Outro** | Slowest, simplest. Crossfade, color dip to black. 0.6-1.0s | Closure. Don't introduce new energy at the end. |
## Blur Intensity by Energy
| Energy | Blur | Duration | Hold at peak |
| ---------- | ------- | -------- | ------------ |
| **Calm** | 20-30px | 0.8-1.2s | 0.3-0.5s |
| **Medium** | 8-15px | 0.4-0.6s | 0.1-0.2s |
| **High** | 3-6px | 0.2-0.3s | 0s |
## Presets
| Preset | Duration | Easing |
| ---------- | -------- | ----------------- |
| `snappy` | 0.2s | `power4.inOut` |
| `smooth` | 0.4s | `power2.inOut` |
| `gentle` | 0.6s | `sine.inOut` |
| `dramatic` | 0.5s | `power3.in` → out |
| `instant` | 0.15s | `expo.inOut` |
| `luxe` | 0.7s | `power1.inOut` |
## Implementation
Read [transitions/catalog.md](transitions/catalog.md) for GSAP code and hard rules for every transition type.
| Category | CSS | Shader (WebGL) |
| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- |
| Push/slide | Push slide, vertical push, elastic push, squeeze | Whip pan |
| Scale/zoom | Zoom through, zoom out, gravity drop, 3D flip | Cinematic zoom, gravitational lens |
| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | SDF iris |
| Dissolve | Crossfade, blur crossfade, focus pull, color dip | Cross-warp morph, domain warp |
| Cover | Staggered blocks, horizontal blinds, vertical blinds | — |
| Light | Light leak, overexposure burn, film burn | Light leak (shader), thermal distortion |
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | Glitch (shader), chromatic split, ridged burn, ripple waves, swirl vortex |
| Pattern | Grid dissolve, morph circle | — |
## Transitions That Don't Work in CSS
Avoid: star iris, tilt-shift, lens flare, hinge/door. See catalog.md for why.
## CSS vs Shader
CSS transitions animate scene containers with opacity, transforms, clip-path, and filters. Shader transitions composite both scene textures per-pixel on a WebGL canvas — they can warp, dissolve, and morph in ways CSS cannot.
**Both are first-class options.** Shaders are provided by the `@hyperframes/shader-transitions` package — import from the package instead of writing raw GLSL. CSS transitions are simpler to set up. Choose based on the effect you want, not based on which is easier.
When a composition uses shader transitions, ALL transitions in that composition should be shader-based (the WebGL canvas replaces DOM-based scene switching). Don't mix CSS and shader transitions in the same composition.
## Shader-Compatible CSS Rules
Shader transitions capture DOM scenes to WebGL textures via html2canvas. The canvas 2D rendering pipeline doesn't match CSS exactly. Follow these rules to avoid visible artifacts at transition boundaries:
1. **No `transparent` keyword in gradients.** Canvas interpolates `transparent` as `rgba(0,0,0,0)` (black at zero alpha), creating dark fringes. Always use the target color at zero alpha: `rgba(200,117,51,0)` not `transparent`.
2. **No gradient backgrounds on elements thinner than 4px.** Canvas can't match CSS gradient rendering on 1-2px elements. Use solid `background-color` on thin accent lines.
3. **No CSS variables (`var()`) on elements visible during capture.** html2canvas doesn't reliably resolve custom properties. Use literal color values in inline styles.
4. **Mark uncapturable decorative elements with `data-no-capture`.** The capture function skips these. They're present on the live DOM but absent from the shader texture. Use for elements that can't follow the rules above.
5. **No gradient opacity below 0.15.** Gradient elements below 10% opacity render differently in canvas vs CSS. Increase to 0.15+ or use a solid color at equivalent brightness.
6. **Every `.scene` div must have explicit `background-color`, AND pass the same color as `bgColor` in the `init()` config.** The package captures scene elements via html2canvas. Both the CSS `background-color` on `.scene` and the `bgColor` config must match. Without either, the texture renders as black.
These rules only apply to shader transition compositions. CSS-only compositions have no restrictions.
## Visual Pattern Warning
Avoid transitions that create visible repeating geometric patterns — grids of tiles, hexagonal cells, uniform dot arrays, evenly-spaced blob circles. These look cheap and artificial regardless of the math behind them. Organic noise (FBM, domain warping) is good because it's irregular. Geometric repetition is bad because the eye instantly sees the grid.
@@ -0,0 +1,117 @@
# Transition Catalog
Hard rules, scene template, and routing to implementation code. Read the reference file for the transition type you need — don't load all of them.
## Hard Rules (CSS)
These cause real bugs if violated.
**Scene visibility:** Scene 1 visible by default (no `opacity: 0`). Scenes 2+ have `opacity: 0` on the CONTAINER div. GSAP reveals them. No visibility shim (`timedEls`).
**Fonts:** Just write the `font-family` you want — the compiler embeds supported fonts automatically via `@font-face` with inline data URIs. No need for `<link>` tags or `@import`. Works in all contexts including sandboxed iframes.
**Element structure:** No `class="clip"` on scene divs in standalone compositions. Only the root div gets `data-composition-id`/`data-start`/`data-duration`.
**Overlay elements:** Staggered blocks = full-screen 1920x1080, NOT thin strips. Glitch RGB overlays = normal blending at 35% opacity, NOT `mix-blend-mode: multiply` (invisible on dark backgrounds). Light leak overlays = larger than the frame (2400px+), never a visible shape. Overexposure = use `filter: brightness()` on the scene, not just a white overlay.
**VHS tape:** Clone actual scene content with `cloneNode(true)`, NOT colored bars. Each strip: wider than frame (2020px at left:-50px). Red+blue chromatic copies at z-index above main strip. Seeded PRNG for deterministic random offsets.
**Z-index:** Gravity drop, zoom out, diagonal split need outgoing scene ON TOP (`zIndex: 10`) so it exits while revealing the new scene behind (`zIndex: 1`).
**Page burn:** Content burns with the page — no falling debris. Hide scene1 via `tl.set` at burn end, NEVER `onComplete` (not reversible). `onUpdate` must restore `clipPath: "none"` when `wp <= 0` for rewind support. Incoming scene fades from black at 90% through burn.
**Clock wipe:** 9-point polygon with intermediate edge positions. Step through 4 quadrants with separate tweens.
**Grid dissolve:** Cycle 5 palette colors per cell, not monochrome.
**Blinds count by energy:** Calm: 4h/6v. Medium: 6-8h/8v. High: 12-16h/16v.
**Don't use:** Star iris (polygon interpolation broken), tilt-shift (no selective CSS blur), lens flare (visible shape, not optical), hinge/door (distorts too fast).
## Shader Transitions
Shader setup, WebGL init, capture, and fragment shaders are handled by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). Read the package source for API details. Compositions using shaders must follow the CSS rules in [transitions.md](../transitions.md) § "Shader-Compatible CSS Rules".
## Scene Template
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #000;
font-family: "YOUR FONT", sans-serif; /* compiler embeds supported fonts automatically */
}
.scene {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
}
#scene1 {
z-index: 1;
background: #color;
}
#scene2 {
z-index: 2;
background: #color;
opacity: 0;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-width="1920"
data-height="1080"
data-start="0"
data-duration="TOTAL"
>
<div id="scene1" class="scene"><!-- visible --></div>
<div id="scene2" class="scene"><!-- hidden --></div>
</div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// Transition code here
window.__timelines["main"] = tl;
</script>
</body>
</html>
```
Every transition follows: position new scene → animate outgoing → swap → animate incoming → clean up overlays.
## CSS Transitions
All code examples use `old` for the outgoing scene-inner selector and `new` for the incoming, with `T` as the transition start time. Read the reference file for the type you need.
| Type | Transitions | Reference |
| -------------- | ---------------------------------------------------- | ------------------------------------------ |
| Push | Push slide, vertical push, elastic push, squeeze | [css-push.md](./css-push.md) |
| Radial / Shape | Circle iris, diamond iris, diagonal split | [css-radial.md](./css-radial.md) |
| 3D | 3D card flip | [css-3d.md](./css-3d.md) |
| Scale / Zoom | Zoom through, zoom out | [css-scale.md](./css-scale.md) |
| Dissolve | Crossfade, blur crossfade, focus pull, color dip | [css-dissolve.md](./css-dissolve.md) |
| Cover | Staggered blocks, horizontal blinds, vertical blinds | [css-cover.md](./css-cover.md) |
| Light | Light leak, overexposure burn, film burn | [css-light.md](./css-light.md) |
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | [css-distortion.md](./css-distortion.md) |
| Mechanical | Shutter, clock wipe | [css-mechanical.md](./css-mechanical.md) |
| Grid | Grid dissolve | [css-grid.md](./css-grid.md) |
| Other | Gravity drop, morph circle | [css-other.md](./css-other.md) |
| Blur | Blur through, directional blur | [css-blur.md](./css-blur.md) |
| Destruction | Page burn | [css-destruction.md](./css-destruction.md) |
## Shader Transitions
WebGL shader transitions are provided by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). The package handles setup, capture, WebGL init, render loop, and GSAP integration. Read the package source for available shaders and API — do not copy raw GLSL manually.
@@ -0,0 +1,12 @@
## 3D
### 3D Card Flip
180° Y-axis rotation. Requires CSS: `backface-visibility: hidden; transform-style: preserve-3d;` on both scene-inners. Parent needs `perspective: 1200px`.
```js
tl.set(new, { rotationY: -180, opacity: 1 }, T);
tl.to(old, { rotationY: 180, duration: 0.6, ease: "power2.inOut" }, T);
tl.to(new, { rotationY: 0, duration: 0.6, ease: "power2.inOut" }, T);
tl.set(old, { opacity: 0 }, T + 0.6);
```
@@ -0,0 +1,51 @@
## Blur
All blur transitions scale with energy. See SKILL.md "Blur Intensity by Energy" for the full table.
### Blur Through
Content becomes fully abstract before resolving. The heaviest blur transition.
**Calm (default for this type — it's inherently heavy):**
```js
tl.to(old, { filter: "blur(30px)", scale: 1.08, duration: 0.5, ease: "power1.in" }, T);
tl.to(old, { opacity: 0, duration: 0.3, ease: "power1.in" }, T + 0.3);
// Hold: both scenes in abstract blur state
tl.fromTo(new,
{ filter: "blur(30px)", scale: 0.92, opacity: 0 },
{ filter: "blur(30px)", scale: 0.92, opacity: 1, duration: 0.2, ease: "none" }, T + 0.5);
// Slow resolve
tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.7, ease: "power1.out" }, T + 0.7);
```
**Medium:**
```js
tl.to(old, { filter: "blur(15px)", scale: 1.05, opacity: 0, duration: 0.4, ease: "power2.in" }, T);
tl.fromTo(new,
{ filter: "blur(15px)", scale: 0.95, opacity: 0 },
{ filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.4, ease: "power2.out" }, T + 0.2);
```
### Directional Blur
Blur + skew simulating motion in one direction. Scale blur and skew with energy.
**Medium (default):**
```js
tl.to(old, { filter: "blur(12px)", skewX: -8, x: -200, opacity: 0, duration: 0.4, ease: "power3.in" }, T);
tl.fromTo(new,
{ filter: "blur(12px)", skewX: 8, x: 200, opacity: 0 },
{ filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.4, ease: "power3.out" }, T + 0.15);
```
**Calm (heavier blur, gentler motion):**
```js
tl.to(old, { filter: "blur(20px)", skewX: -4, x: -100, opacity: 0, duration: 0.6, ease: "power1.in" }, T);
tl.fromTo(new,
{ filter: "blur(20px)", skewX: 4, x: 100, opacity: 0 },
{ filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.6, ease: "power1.out" }, T + 0.3);
```
@@ -0,0 +1,43 @@
## Cover
### Staggered Color Blocks
Full-screen (1920x1080) colored divs slide across staggered. Scene swaps while covered.
**2-block** (standard):
```js
tl.set("#wipe-a", { x: -1920 }, T - 0.01);
tl.set("#wipe-b", { x: -1920 }, T - 0.01);
tl.to("#wipe-a", { x: 0, duration: 0.25, ease: "power3.inOut" }, T);
tl.to("#wipe-b", { x: 0, duration: 0.25, ease: "power3.inOut" }, T + 0.06);
tl.set(old, { opacity: 0 }, T + 0.2);
tl.set(new, { opacity: 1 }, T + 0.2);
tl.to("#wipe-a", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.28);
tl.to("#wipe-b", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.34);
```
**5-block** (dense variant): same pattern with 5 blocks at 0.04s stagger. Use composition palette colors.
### Horizontal Blinds
Full-width strips slide across staggered. Each strip: `width: 1920px; height: Xpx`.
**6 strips** (180px each): `0.03s` stagger
**12 strips** (90px each): `0.018s` stagger
```js
for (var i = 0; i < N; i++) {
tl.set("#blind-h-" + i, { x: -1920 }, T - 0.01);
tl.fromTo("#blind-h-" + i, { x: -1920 }, { x: 0, duration: 0.2, ease: "power3.inOut" }, T + i * stagger);
}
tl.set(old, { opacity: 0 }, T + coverTime);
tl.set(new, { opacity: 1 }, T + coverTime);
for (var i = 0; i < N; i++) {
tl.to("#blind-h-" + i, { x: 1920, duration: 0.2, ease: "power3.inOut" }, T + exitStart + i * stagger);
}
```
### Vertical Blinds
Same as horizontal but strips are tall and narrow, moving on Y axis.
@@ -0,0 +1,95 @@
## Destruction
### Page Burn
The outgoing scene literally burns away from a corner. A fire front expands with noise-based irregular edges, a canvas draws the scorched char line at the burn boundary, and individual text characters/elements chip off and fall with gravity as the fire reaches them. The incoming scene reveals behind the burn.
This transition has three systems working together:
1. **Fire geometry** — a radial front expanding from a corner (e.g., bottom-right) with noise-based irregularity for organic edges
2. **Scene clipping** — the outgoing scene uses an SVG clip-path (with `fill-rule: evenodd`) that cuts a hole matching the fire front. As the fire expands, more of the scene is clipped away. All content (text, images, lines) burns with the page — no separate debris.
3. **Scorched edge** — a `<canvas>` overlay draws a radial gradient fringe at the fire boundary to simulate charring
**When to use:** Dramatic reveals, edgy/destructive mood, gaming, cyberpunk. This is the most dramatic transition in the catalog — reserve it for hero moments.
**Requirements:**
- A `<canvas>` element for the burn edge overlay
- A noise function for organic fire edge geometry
- SVG clip-path with evenodd fill-rule for the inverted clip
**Fire geometry (deterministic noise):**
```js
function noise(x) {
var ix = Math.floor(x),
fx = x - ix;
var a = Math.sin(ix * 127.1 + 311.7) * 43758.5453;
var b = Math.sin((ix + 1) * 127.1 + 311.7) * 43758.5453;
var t = fx * fx * (3 - 2 * fx);
return a - Math.floor(a) + (b - Math.floor(b) - (a - Math.floor(a))) * t;
}
function fireRadiusAtAngle(angle, progress) {
var base = progress * maxRadius;
return (
base +
noise(angle * 3 + progress * 4) * 50 +
noise(angle * 8 + progress * 9) * 20 +
noise(angle * 15 + progress * 15) * 8
);
}
```
**Incoming scene timing:** The incoming scene should NOT be visible during the burn. As the fire consumes the outgoing scene, **black shows through the holes** — this is the dramatic part. The viewer watches content being destroyed against blackness.
At ~90% through the burn, the incoming scene fades in SLOWLY from black — the background first, then content staggered. Use long, gentle fades (`power1.out`, 0.8-1.2s durations) so it feels like the new scene materializes from darkness, not a hard swap.
```js
// Scene 2 stays at opacity: 0 during the burn — black behind the fire
tl.set("#s2-title", { opacity: 0 }, T);
tl.set("#s2-subtitle", { opacity: 0 }, T);
// At 90% through, scene bg fades in slowly from black
var contentReveal = T + BURN_DURATION * 0.9;
tl.to("#scene2", { opacity: 1, duration: 1.2, ease: "power1.out" }, contentReveal);
// Content fades in staggered on top, even slower
tl.to("#s2-title", { opacity: 1, duration: 1.0, ease: "power1.out" }, contentReveal + 0.5);
tl.to("#s2-subtitle", { opacity: 1, duration: 0.8, ease: "power1.out" }, contentReveal + 0.7);
```
**Content burns with the page — no falling debris.** The clip-path on scene1 IS the effect — as the fire shape expands, everything behind the fire edge (text, images, lines) disappears naturally. Don't clone elements, don't create falling debris. The content is part of the page being consumed. The scorched canvas edge provides the visual char line at the burn boundary.
**Hide scene1 via `tl.set` at burn end — NEVER in `onComplete`.** Using `onComplete` to hide scene1 is not reversible when scrubbing. Instead, use a `tl.set` at the exact burn end time:
```js
tl.to(
burnState,
{
progress: 1,
duration: BURN_DURATION,
ease: "none",
onUpdate: function () {
var wp = burnState.progress;
var scene1 = document.getElementById("scene1");
if (wp <= 0) {
scene1.style.clipPath = "none"; // fully visible when rewound
} else if (wp < 1) {
scene1.style.clipPath = buildClipPath(wp);
}
drawEdge(wp);
},
// NO onComplete — use tl.set instead
},
T,
);
// Hide scene1 at exact burn end — reversible via timeline
tl.set("#scene1", { opacity: 0 }, T + BURN_DURATION);
tl.set("#scene1", { clipPath: "none" }, T + BURN_DURATION);
```
The `onUpdate` handles clip-path and canvas edge per-frame. The `tl.set` handles the final hide — and GSAP automatically reverses it when scrubbing backward, restoring scene1 to `opacity: 1`.
The `onUpdate` callback is the key — it runs every frame to advance the clip-path and canvas edge in sync with the timeline.
@@ -0,0 +1,66 @@
## Dissolve
### Crossfade
Simple opacity swap. The baseline.
```js
tl.to(old, { opacity: 0, duration: 0.5, ease: "power2.inOut" }, T);
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.inOut" }, T);
```
### Blur Crossfade
Dissolve with blur + scale shift. **Scale blur amount by energy** — see SKILL.md "Blur Intensity by Energy" section. The examples below show the medium (default) version. For calm compositions, increase to 20-30px with a 0.3-0.5s hold at peak blur. For high-energy, decrease to 3-6px with no hold.
**Medium (default):**
```js
tl.to(old, { filter: "blur(10px)", scale: 1.03, opacity: 0, duration: 0.5, ease: "power2.inOut" }, T);
tl.fromTo(new,
{ filter: "blur(10px)", scale: 0.97, opacity: 0 },
{ filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.5, ease: "power2.inOut" }, T + 0.1);
```
**Calm (wellness, luxury) — heavy blur, holds at abstract color:**
```js
tl.to(old, { filter: "blur(25px)", scale: 1.05, duration: 0.6, ease: "power1.in" }, T);
tl.to(old, { opacity: 0, duration: 0.4, ease: "power1.in" }, T + 0.4);
tl.fromTo(new,
{ filter: "blur(25px)", scale: 0.95, opacity: 0 },
{ filter: "blur(25px)", scale: 0.95, opacity: 1, duration: 0.3, ease: "power1.inOut" }, T + 0.5);
tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.6, ease: "power1.out" }, T + 0.8);
```
### Focus Pull
Outgoing slowly blurs while incoming fades in sharp. Depth-of-field feel. **Scale blur amount and hold duration by energy.**
**Medium:**
```js
tl.to(old, { filter: "blur(15px)", duration: 0.5, ease: "power1.in" }, T);
tl.to(old, { opacity: 0, duration: 0.3, ease: "power2.in" }, T + 0.25);
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out" }, T + 0.25);
```
**Calm — slow rack focus with long hold at peak defocus:**
```js
tl.to(old, { filter: "blur(30px)", duration: 0.8, ease: "power1.in" }, T);
tl.to(old, { opacity: 0, duration: 0.5, ease: "power1.in" }, T + 0.6);
tl.fromTo(new, { opacity: 0, filter: "blur(20px)" },
{ opacity: 1, filter: "blur(20px)", duration: 0.3, ease: "power1.inOut" }, T + 0.7);
tl.to(new, { filter: "blur(0px)", duration: 0.6, ease: "power1.out" }, T + 1.0);
```
### Color Dip
Fade to solid color, hold, fade up new scene.
```js
tl.to(old, { opacity: 0, duration: 0.2, ease: "power2.in" }, T);
// Background color shows through
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power2.out" }, T + 0.25);
```
@@ -0,0 +1,45 @@
## Distortion
### Glitch
RGB-tinted overlays (NOT multiply blend — use normal blending at 35% opacity) jitter with large offsets. Scene itself also jitters.
```js
tl.set("#glitch-r", { opacity: 1, x: 40, y: -8 }, T);
tl.set("#glitch-g", { opacity: 1, x: -30, y: 12 }, T);
tl.set("#glitch-b", { opacity: 1, x: 15, y: -20 }, T);
tl.set(old, { x: -15 }, T);
// 6 jitter frames at 0.03s intervals with big offsets (±30-60px)
// ... swap and clear at T + 0.2
```
### Chromatic Aberration
RGB overlays start aligned then spread apart (±80px), scene fades, converge on new scene.
```js
tl.set("#glitch-r", { opacity: 0.6, x: 0 }, T);
tl.set("#glitch-g", { opacity: 0.6, x: 0 }, T);
tl.set("#glitch-b", { opacity: 0.6, x: 0 }, T);
tl.to("#glitch-r", { x: -80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T);
tl.to("#glitch-b", { x: 80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T);
tl.to("#glitch-g", { y: 30, duration: 0.3, ease: "power2.in" }, T);
// Swap at T + 0.3, converge back at T + 0.3
```
### Ripple
Rapid oscillation (±30px) + scale distortion (0.97-1.03) + increasing blur. Swap at peak distortion.
```js
tl.to(old, { x: 30, scale: 1.02, duration: 0.04, ease: "none" }, T);
tl.to(old, { x: -25, scale: 0.98, filter: "blur(4px)", duration: 0.04, ease: "none" }, T + 0.04);
// ... more oscillations with increasing blur
// Swap at peak, incoming stabilizes with decreasing wobble
```
### VHS Tape
Clone scene into 20 horizontal strips (each 54px, clip-path'd). Each strip shifts x independently with seeded pseudo-random offsets at per-bar random intervals. Add red+blue chromatic offset copies on each strip (z-index above main, 35% opacity). Make strips wider than frame (2020px at left:-50px) so edges never show.
See SKILL.md for clone-based implementation pattern.
@@ -0,0 +1,10 @@
## Grid
### Grid Dissolve
Grid of colored cells covers the frame in a ripple from center. Scene swaps at 50% coverage. Cells fade out in ripple.
**12-cell** (4x3, each 480x270): standard
**120-cell** (12x10, each 160x108): dense variant — lower opacity (0.75), tighter ripple
Cells are created dynamically in JS, sorted by distance from center for ripple stagger.
@@ -0,0 +1,49 @@
## Light
### Light Leak
Multiple warm-colored overlays wash across frame. Needs: a flat warm tint layer + 2-3 bright radial gradient divs, all larger than the frame so edges are never visible.
```js
// Warm tint washes over entire frame
tl.to("#leak-warm", { opacity: 0.4, duration: 0.3, ease: "power1.in" }, T);
// Bright leak elements drift in
tl.to("#leak-1", { opacity: 0.9, x: 300, duration: 0.5, ease: "sine.inOut" }, T + 0.05);
tl.to("#leak-2", { opacity: 0.8, x: 200, duration: 0.6, ease: "sine.inOut" }, T + 0.1);
// Peak warmth then swap
tl.to("#leak-warm", { opacity: 0.6, duration: 0.15, ease: "power2.in" }, T + 0.35);
tl.set(old, { opacity: 0 }, T + 0.45);
tl.set(new, { opacity: 1 }, T + 0.45);
// Leak fades
tl.to("#leak-warm", { opacity: 0, duration: 0.4, ease: "power2.out" }, T + 0.5);
tl.to("#leak-1", { opacity: 0, x: 600, duration: 0.35, ease: "power1.out" }, T + 0.5);
```
### Overexposure Burn
Scene progressively blows out to white using CSS `filter: brightness()`, then white overlay fades in. Swap at peak white. White recedes to reveal new scene.
```js
tl.to(old, { filter: "brightness(1.5)", scale: 1.03, duration: 0.2, ease: "power1.in" }, T);
tl.to(old, { filter: "brightness(3)", scale: 1.06, duration: 0.2, ease: "power2.in" }, T + 0.2);
tl.to("#flash-overlay", { opacity: 0.5, duration: 0.25, ease: "power1.in" }, T + 0.15);
tl.to("#flash-overlay", { opacity: 1, duration: 0.15, ease: "power2.in" }, T + 0.4);
tl.set(old, { opacity: 0, filter: "brightness(1)", scale: 1 }, T + 0.55);
tl.set(new, { opacity: 1 }, T + 0.55);
tl.to("#flash-overlay", { opacity: 0, duration: 0.35, ease: "power2.out" }, T + 0.55);
```
### Film Burn
Staggered warm overlays (amber, orange, red) bleed from one edge. Each overlay is a large radial gradient div at high z-index.
```js
tl.to("#burn-a", { opacity: 1, x: -300, duration: 0.4, ease: "power1.in" }, T);
tl.to("#burn-b", { opacity: 1, x: -500, duration: 0.5, ease: "power1.in" }, T + 0.05);
tl.to("#burn-c", { opacity: 1, x: -200, duration: 0.45, ease: "power1.in" }, T + 0.1);
tl.set(old, { opacity: 0 }, T + 0.35);
tl.set(new, { opacity: 1 }, T + 0.35);
tl.to("#burn-a", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.45);
tl.to("#burn-b", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.5);
tl.to("#burn-c", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.55);
```
@@ -0,0 +1,30 @@
## Mechanical
### Shutter
Two full-screen halves close from top and bottom, meet in the middle. Swap while closed. Open again.
```js
tl.to("#shutter-top", { y: 0, duration: 0.25, ease: "power3.in" }, T);
tl.to("#shutter-bot", { y: 0, duration: 0.25, ease: "power3.in" }, T);
tl.set(old, { opacity: 0 }, T + 0.25);
tl.set(new, { opacity: 1 }, T + 0.25);
tl.to("#shutter-top", { y: -540, duration: 0.25, ease: "power3.out" }, T + 0.3);
tl.to("#shutter-bot", { y: 540, duration: 0.25, ease: "power3.out" }, T + 0.3);
```
### Clock Wipe
Radial polygon sweep stepping through quadrants. Use 9-point polygon with intermediate edge positions for smooth sweep.
```js
tl.set(new, { opacity: 1, zIndex: 10 }, T);
var d = 0.1; // duration per quadrant
tl.set(new, { clipPath: "polygon(50% 50%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%)" }, T);
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%)", duration: d, ease: "none" }, T);
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%)", duration: d, ease: "none" }, T + d);
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 50%)", duration: d, ease: "none" }, T + d*2);
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 0%)", duration: d, ease: "none" }, T + d*3);
tl.set(new, { clipPath: "none", zIndex: "auto" }, T + d*4 + 0.02);
tl.set(old, { opacity: 0, zIndex: "auto" }, T + d*4 + 0.02);
```
@@ -0,0 +1,25 @@
## Other
### Gravity Drop
Old scene falls down with slight rotation. New scene was behind it. Needs z-index.
```js
tl.set(new, { opacity: 1, zIndex: 1 }, T);
tl.set(old, { zIndex: 10 }, T);
tl.to(old, { y: 1200, rotation: 4, duration: 0.5, ease: "power3.in" }, T);
tl.set(old, { opacity: 0, zIndex: "auto" }, T + 0.5);
tl.set(new, { zIndex: "auto" }, T + 0.5);
```
### Morph Circle
A circle scales up from center to fill frame (becoming the new scene's background color). New scene content fades in on top.
```js
tl.set("#morph-circle", { background: newBgColor, opacity: 1, scale: 0 }, T);
tl.to("#morph-circle", { scale: 30, duration: 0.5, ease: "power3.in" }, T);
tl.set(old, { opacity: 0 }, T + 0.4);
tl.set(new, { opacity: 1 }, T + 0.4);
tl.to("#morph-circle", { opacity: 0, duration: 0.15, ease: "power2.out" }, T + 0.5);
```
@@ -0,0 +1,41 @@
## Linear / Push
### Push Slide
Both scenes move together — new pushes old out.
```js
tl.to(old, { x: -1920, duration: 0.5, ease: "power3.inOut" }, T);
tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 0, duration: 0.5, ease: "power3.inOut" }, T);
```
### Vertical Push
Same as push slide but vertical.
```js
tl.to(old, { y: -1080, duration: 0.5, ease: "power3.inOut" }, T);
tl.fromTo(new, { y: 1080, opacity: 1 }, { y: 0, duration: 0.5, ease: "power3.inOut" }, T);
```
### Elastic Push
Push with overshoot bounce on the incoming scene.
```js
tl.to(old, { x: -1920, duration: 0.5, ease: "power3.in" }, T);
tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 30, duration: 0.4, ease: "power4.out" }, T + 0.1);
tl.to(new, { x: -15, duration: 0.15, ease: "sine.inOut" }, T + 0.5);
tl.to(new, { x: 0, duration: 0.1, ease: "sine.out" }, T + 0.65);
```
### Squeeze
Old compresses, new expands from opposite side.
```js
tl.to(old, { scaleX: 0, transformOrigin: "left center", duration: 0.4, ease: "power3.inOut" }, T);
tl.fromTo(new, { scaleX: 0, transformOrigin: "right center", opacity: 1 },
{ scaleX: 1, duration: 0.4, ease: "power3.inOut" }, T + 0.1);
tl.set(old, { opacity: 0 }, T + 0.5);
```
@@ -0,0 +1,37 @@
## Radial / Shape
### Circle Iris
Expanding circle from center reveals new scene.
```js
tl.set(new, { opacity: 1 }, T);
tl.fromTo(new,
{ clipPath: "circle(0% at 50% 50%)" },
{ clipPath: "circle(75% at 50% 50%)", duration: 0.5, ease: "power2.out" }, T);
tl.set(old, { opacity: 0 }, T + 0.5);
```
### Diamond Iris
Expanding diamond shape from center.
```js
tl.set(new, { opacity: 1 }, T);
tl.fromTo(new,
{ clipPath: "polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)" },
{ clipPath: "polygon(50% -20%, 120% 50%, 50% 120%, -20% 50%)", duration: 0.5, ease: "power2.out" }, T);
tl.set(old, { opacity: 0 }, T + 0.5);
```
### Diagonal Split
Old scene shrinks to a triangle in one corner.
```js
tl.set(new, { opacity: 1, zIndex: 1 }, T);
tl.set(old, { zIndex: 10, clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }, T);
tl.to(old, { clipPath: "polygon(60% 0%, 100% 0%, 100% 40%, 60% 0%)", duration: 0.5, ease: "power3.inOut" }, T);
tl.set(old, { opacity: 0, zIndex: "auto", clipPath: "none" }, T + 0.5);
tl.set(new, { zIndex: "auto" }, T + 0.5);
```
@@ -0,0 +1,24 @@
## Scale / Zoom
### Zoom Through
Old zooms past camera + blurs, new zooms in from behind.
```js
tl.to(old, { scale: 2.5, opacity: 0, filter: "blur(8px)", duration: 0.4, ease: "power3.in" }, T);
tl.fromTo(new,
{ scale: 0.5, opacity: 0, filter: "blur(8px)" },
{ scale: 1, opacity: 1, filter: "blur(0px)", duration: 0.4, ease: "power3.out" }, T + 0.15);
```
### Zoom Out
Old shrinks away, new was behind it. Needs z-index management.
```js
tl.set(new, { opacity: 1, zIndex: 1 }, T);
tl.set(old, { zIndex: 10, transformOrigin: "50% 50%" }, T);
tl.to(old, { scale: 0.3, opacity: 0, duration: 0.4, ease: "power3.in" }, T);
tl.set(old, { zIndex: "auto" }, T + 0.4);
tl.set(new, { zIndex: "auto" }, T + 0.4);
```
@@ -0,0 +1,56 @@
# Text-to-Speech
Generate speech audio locally using Kokoro-82M (no API key, runs on CPU).
## Voice Selection
Match voice to content. Default is `af_heart`.
| Content type | Voice | Why |
| ------------- | --------------------- | -------------------------- |
| Product demo | `af_heart`/`af_nova` | Warm, professional |
| Tutorial | `am_adam`/`bf_emma` | Neutral, easy to follow |
| Marketing | `af_sky`/`am_michael` | Energetic or authoritative |
| Documentation | `bf_emma`/`bm_george` | Clear British English |
| Casual | `af_heart`/`af_sky` | Approachable, natural |
Run `npx hyperframes tts --list` for all 54 voices (8 languages).
## Speed Tuning
- **0.7-0.8** — Tutorial, complex content
- **1.0** — Natural pace (default)
- **1.1-1.2** — Intros, upbeat content
- **1.5+** — Rarely appropriate
## Usage
```bash
npx hyperframes tts "Your script here" --voice af_nova --output narration.wav
npx hyperframes tts script.txt --voice bf_emma --output narration.wav
```
In compositions:
```html
<audio
id="narration"
data-start="0"
data-duration="auto"
data-track-index="2"
src="narration.wav"
data-volume="1"
></audio>
```
## TTS + Captions Workflow
```bash
npx hyperframes tts script.txt --voice af_heart --output narration.wav
npx hyperframes transcribe narration.wav # → transcript.json with word-level timestamps
```
## Requirements
- Python 3.8+ with `kokoro-onnx` and `soundfile`
- Model downloads on first use (~311 MB + ~27 MB voices, cached in `~/.cache/hyperframes/tts/`)
@@ -0,0 +1,175 @@
# Typography
The compiler embeds supported fonts — just write `font-family` in CSS.
## Banned
Training-data defaults that every LLM reaches for. These produce monoculture across compositions.
Inter, Roboto, Open Sans, Noto Sans, Arimo, Lato, Source Sans, PT Sans, Nunito, Poppins, Outfit, Sora, Playfair Display, Cormorant Garamond, Bodoni Moda, EB Garamond, Cinzel, Prata, Syne
**Syne in particular** is the most overused "distinctive" display font. It is an instant AI design tell.
## Guardrails
You know these rules but you violate them. Stop.
- **Don't pair two sans-serifs.** You do this constantly — one for headlines, one for body. Cross the boundary: serif + sans, or sans + mono.
- **One expressive font per scene.** You pick two interesting fonts trying to make it "better." One performs, one recedes.
- **Weight contrast must be extreme.** You default to 400 vs 700. Video needs 300 vs 900. The difference must be visible in motion at a glance.
- **Video sizes, not web sizes.** Body: 20px minimum. Headlines: 60px+. Data labels: 16px. You will try to use 14px. Don't.
## What You Don't Do Without Being Told
- **Tension should mean something.** Don't pattern-match pairings. Ask WHY these two fonts disagree. The pairing should embody the content's contradiction — mechanical vs human, public vs private, institutional vs personal. If you can't articulate the tension, it's arbitrary.
- **Register switching.** Assign different fonts to different communicative modes — one voice for statements, another for data, another for attribution. Not hierarchy on a page. Voices in a conversation.
- **Tension can live inside a single font.** A font that looks familiar but is secretly strange creates tension with the viewer's expectations, not with another font.
- **One variable changed = dramatic contrast.** Same letterforms, monospaced vs proportional. Same family at different optical sizes. Changing only rhythm while everything else stays constant.
- **Double personality works.** Two expressive fonts can coexist if they share an attitude (both irreverent, both precise) even when their forms are completely different.
- **Time is hierarchy.** The first element to appear is the most important. In video, sequence replaces position.
- **Motion is typography.** How a word enters carries as much meaning as the font. A 0.1s slam vs a 2s fade — same font, completely different message.
- **Fixed reading time.** 3 seconds on screen = must be readable in 2. Fewer words, larger type.
- **Tracking tighter than web.** -0.03em to -0.05em on display sizes. Video encoding compresses letter detail.
## Finding Fonts
Don't default to what you know. If the content is luxury, a grotesque sans might create more tension than the expected Didone serif. Decide the register first, then search.
Save this script to `/tmp/fontquery.py` and run with `curl -s 'https://fonts.google.com/metadata/fonts' > /tmp/gfonts.json && python3 /tmp/fontquery.py /tmp/gfonts.json`:
```python
import json, sys, random
from collections import OrderedDict
random.seed() # true random each run
with open(sys.argv[1]) as f:
data = json.load(f)
fonts = data.get("familyMetadataList", [])
ban = {"Inter","Roboto","Open Sans","Noto Sans","Lato","Poppins","Source Sans 3",
"PT Sans","Nunito","Outfit","Sora","Playfair Display","Cormorant Garamond",
"Bodoni Moda","EB Garamond","Cinzel","Prata","Arimo","Source Sans Pro","Syne"}
skip_pfx = ("Roboto","Noto ","Google Sans","Bpmf","Playwrite","Anek","BIZ ",
"Nanum","Shippori","Sawarabi","Zen ","Kaisei","Kiwi ","Yuji ","Radio ")
def ok(f):
if f["family"] in ban: return False
if any(f["family"].startswith(b) for b in skip_pfx): return False
if "latin" not in (f.get("subsets") or []): return False
return True
seen = set()
R = OrderedDict()
# Trending Sans — recent (2022+), popular (<300)
R["Trending Sans"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") in ("Sans Serif","Display") and f.get("dateAdded","") >= "2022-01-01" and f.get("popularity",9999) < 300:
R["Trending Sans"].append(f); seen.add(f["family"])
# Trending Serif — recent (2018+), popular (<600)
R["Trending Serif"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Serif" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
R["Trending Serif"].append(f); seen.add(f["family"])
# Monospace — recent (2018+), popular (<600)
R["Monospace"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Monospace" and f.get("dateAdded","") >= "2018-01-01" and f.get("popularity",9999) < 600:
R["Monospace"].append(f); seen.add(f["family"])
# Impact & Condensed — heavy display fonts with 800+ weight
R["Impact & Condensed"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
has_heavy = any(k in list(f.get("fonts",{}).keys()) for k in ("800","900"))
is_display = f.get("category") in ("Sans Serif","Display")
if has_heavy and is_display and f.get("popularity",9999) < 400:
R["Impact & Condensed"].append(f); seen.add(f["family"])
# Script & Handwriting — popular (<300)
R["Script & Handwriting"] = []
for f in fonts:
if not ok(f) or f["family"] in seen: continue
if f.get("category") == "Handwriting" and f.get("popularity",9999) < 300:
R["Script & Handwriting"].append(f); seen.add(f["family"])
# Randomize the top 5 in each category so the LLM doesn't always pick the same first result
for cat in R:
R[cat].sort(key=lambda x: x.get("popularity",9999))
top5 = R[cat][:5]
rest = R[cat][5:]
random.shuffle(top5)
R[cat] = top5 + rest
limits = {"Trending Sans":15,"Trending Serif":12,"Monospace":8,
"Impact & Condensed":12,"Script & Handwriting":10}
for cat in R:
items = R[cat][:limits.get(cat,10)]
if not items: continue
print(f"--- {cat} ({len(items)}) ---")
for ff in items:
var = "VAR" if ff.get("axes") else " "
print(f' {ff.get("popularity"):4d} | {var} | {ff["family"]}')
print()
```
Five categories: trending sans, trending serif, monospace, impact/condensed, script/handwriting. All dynamically filtered from Google Fonts metadata — no hardcoded font names. Cross classification boundaries when pairing.
## Selection Thinking
Don't pick fonts by category reflex (editorial → serif, tech → mono, modern → geometric sans). That's pattern matching, not design.
1. **Name the register.** What voice is the content speaking in? Institutional authority? Personal confession? Technical precision? Casual irreverence? The register narrows the field more than the category.
2. **Think physically.** Imagine the font as a physical object the brand could ship — a museum exhibit caption, a hand-painted shop sign, a 1970s mainframe terminal manual, a fabric label inside a coat, a children's book printed on cheap newsprint, a tax form. Whichever physical object fits the register is pointing at the right _kind_ of typeface.
3. **Reject your first instinct.** The first font that feels right is usually your training-data default for that register. If you picked it last time too, find something else.
4. **Cross-check the assumption.** An editorial brief does NOT need a serif. A technical brief does NOT need a sans. A children's product does NOT need a rounded display font. The most distinctive choice often contradicts the category expectation.
## Similar-Font Pairing
Never pair two fonts that are similar but not identical — two geometric sans-serifs, two transitional serifs, two humanist sans. They create visual friction without clear hierarchy. The viewer senses something is "off" but can't articulate it. Either use one font at two weights, or pair fonts that contrast on multiple axes: serif + sans, condensed + wide, geometric + humanist.
## Dark Backgrounds
Light text on dark backgrounds creates two optical illusions you need to compensate for:
- **Increased apparent weight.** Light-on-dark reads heavier than dark-on-light at the same `font-weight`. Use 350 instead of 400 for body text. Headlines are less affected because size compensates.
- **Tighter apparent spacing.** Light halos around letterforms reduce perceived gaps. Increase `line-height` by 0.05-0.1 beyond your light-background value. For display sizes, add 0.01em `letter-spacing` to counteract.
## OpenType Features for Data
Most fonts ship with OpenType features that are off by default. Turn them on for data compositions:
```css
/* Tabular numbers — digits align vertically in columns */
.stat-value,
.timer,
.data-column {
font-variant-numeric: tabular-nums;
}
/* Diagonal fractions — renders 1/2 as ½ */
.recipe-amount,
.ratio {
font-variant-numeric: diagonal-fractions;
}
/* Small caps for abbreviations — less visual shouting */
.abbreviation,
.unit {
font-variant-caps: all-small-caps;
}
/* Disable ligatures in code — fi, fl, ffi should stay separate */
code,
.code {
font-variant-ligatures: none;
}
```
`tabular-nums` is essential any time numbers are stacked vertically — stat callouts, timers, scoreboards, data tables. Without it, digits have proportional widths and columns don't align.
@@ -0,0 +1,596 @@
#!/usr/bin/env node
// animation-map.mjs — HyperFrames animation map for agents
//
// Reads every GSAP timeline registered in window.__timelines, enumerates
// tweens, samples bboxes at N points per tween, computes flags and
// human-readable summaries. Outputs a single animation-map.json.
//
// Usage:
// node skills/hyperframes/scripts/animation-map.mjs <composition-dir> \
// [--frames N] [--out <dir>] [--min-duration S] [--width W] [--height H] [--fps N]
import { mkdir, writeFile } from "node:fs/promises";
import { resolve, join } from "node:path";
import {
createFileServer,
createCaptureSession,
initializeSession,
closeCaptureSession,
getCompositionDuration,
} from "@hyperframes/producer";
// ─── CLI ─────────────────────────────────────────────────────────────────────
const args = parseArgs(process.argv.slice(2));
if (!args.composition) die("missing <composition-dir>");
const FRAMES = Number(args.frames ?? 6);
const OUT_DIR = resolve(args.out ?? ".hyperframes/anim-map");
const MIN_DUR = Number(args["min-duration"] ?? 0.15);
const WIDTH = Number(args.width ?? 1920);
const HEIGHT = Number(args.height ?? 1080);
const FPS = Number(args.fps ?? 30);
const COMP_DIR = resolve(args.composition);
await mkdir(OUT_DIR, { recursive: true });
// ─── Main ────────────────────────────────────────────────────────────────────
const server = await createFileServer({ projectDir: COMP_DIR, port: 0 });
const session = await createCaptureSession(
server.url,
OUT_DIR,
{ width: WIDTH, height: HEIGHT, fps: FPS, format: "png" },
null,
);
await initializeSession(session);
try {
const duration = await getCompositionDuration(session);
const tweens = await enumerateTweens(session);
const kept = tweens.filter((tw) => tw.end - tw.start >= MIN_DUR);
const report = {
composition: COMP_DIR,
duration,
totalTweens: tweens.length,
mappedTweens: kept.length,
skippedMicroTweens: tweens.length - kept.length,
tweens: [],
};
for (let i = 0; i < kept.length; i++) {
const tw = kept[i];
const times = Array.from(
{ length: FRAMES },
(_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3),
);
const bboxes = [];
for (const t of times) {
await seekTo(session, t);
const bbox = await measureTarget(session, tw.selectorHint);
bboxes.push({ t, ...bbox });
}
const animProps = tw.props.filter(
(p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p),
);
const flags = computeFlags(tw, bboxes, { width: WIDTH, height: HEIGHT });
const summary = describeTween(tw, animProps, bboxes, flags);
report.tweens.push({
index: i + 1,
selector: tw.selectorHint,
targets: tw.targetCount,
props: animProps,
start: +tw.start.toFixed(3),
end: +tw.end.toFixed(3),
duration: +(tw.end - tw.start).toFixed(3),
ease: tw.ease,
bboxes,
flags,
summary,
});
}
markCollisions(report.tweens);
for (const tw of report.tweens) {
if (tw.flags.includes("collision") && !tw.summary.includes("collision")) {
tw.summary += " Overlaps another animated element.";
}
}
// ── Composition-level analysis ──
report.choreography = buildTimeline(report.tweens, duration);
report.density = computeDensity(report.tweens, duration);
report.staggers = detectStaggers(report.tweens);
report.elements = buildElementLifecycles(report.tweens);
report.deadZones = findDeadZones(report.density, duration);
report.snapshots = await captureSnapshots(session, report.tweens, duration);
await writeFile(join(OUT_DIR, "animation-map.json"), JSON.stringify(report, null, 2));
printSummary(report);
} finally {
await closeCaptureSession(session).catch(() => {});
server.close();
}
// ─── Seek helper ────────────────────────────────────────────────────────────
async function seekTo(session, t) {
await session.page.evaluate((time) => {
if (window.__hf && typeof window.__hf.seek === "function") {
window.__hf.seek(time);
return;
}
const tls = window.__timelines;
if (tls) {
for (const tl of Object.values(tls)) {
if (typeof tl.seek === "function") tl.seek(time);
}
}
}, t);
await new Promise((r) => setTimeout(r, 100));
}
// ─── Timeline introspection ──────────────────────────────────────────────────
async function enumerateTweens(session) {
return await session.page.evaluate(() => {
const results = [];
const registry = window.__timelines || {};
const selectorOf = (el) => {
if (!el || !(el instanceof Element)) return null;
if (el.id) return `#${el.id}`;
const cls = [...el.classList].slice(0, 2).join(".");
return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase();
};
const walk = (node, parentOffset = 0) => {
if (!node) return;
if (typeof node.getChildren === "function") {
const offset = parentOffset + (node.startTime?.() ?? 0);
for (const child of node.getChildren(true, true, true)) {
walk(child, offset);
}
return;
}
const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element);
if (!targets.length) return;
const vars = node.vars ?? {};
const props = Object.keys(vars).filter(
(k) =>
![
"duration",
"ease",
"delay",
"repeat",
"yoyo",
"onStart",
"onUpdate",
"onComplete",
"stagger",
].includes(k),
);
const start = parentOffset + (node.startTime?.() ?? 0);
const end = start + (node.duration?.() ?? 0);
results.push({
selectorHint: selectorOf(targets[0]) ?? "(unknown)",
targetCount: targets.length,
props,
start,
end,
ease: typeof vars.ease === "string" ? vars.ease : (vars.ease?.toString?.() ?? "none"),
});
};
for (const tl of Object.values(registry)) walk(tl, 0);
results.sort((a, b) => a.start - b.start);
return results;
});
}
async function measureTarget(session, selector) {
return await session.page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return { x: 0, y: 0, w: 0, h: 0, missing: true };
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
opacity: parseFloat(cs.opacity),
visible: cs.visibility !== "hidden" && cs.display !== "none",
};
}, selector);
}
// ─── Tween description (the key output for agents) ──────────────────────────
function describeTween(tw, props, bboxes, flags) {
const dur = (tw.end - tw.start).toFixed(2);
const parts = [];
parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`);
// Movement
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
if (first && last) {
const dx = last.x - first.x;
const dy = last.y - first.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
const dirs = [];
if (Math.abs(dy) > 3) dirs.push(dy < 0 ? `${Math.abs(dy)}px up` : `${Math.abs(dy)}px down`);
if (Math.abs(dx) > 3)
dirs.push(dx < 0 ? `${Math.abs(dx)}px left` : `${Math.abs(dx)}px right`);
parts.push(`moves ${dirs.join(" and ")}`);
}
}
// Opacity
if (first && last && first.opacity !== undefined && last.opacity !== undefined) {
const o1 = first.opacity;
const o2 = last.opacity;
if (Math.abs(o2 - o1) > 0.1) {
if (o1 < 0.1 && o2 > 0.5) parts.push("fades in");
else if (o1 > 0.5 && o2 < 0.1) parts.push("fades out");
else parts.push(`opacity ${o1.toFixed(1)}${o2.toFixed(1)}`);
}
}
// Scale (from props)
if (props.includes("scale") || props.includes("scaleX") || props.includes("scaleY")) {
parts.push("scales");
}
// Size changes
if (first && last) {
const dw = last.w - first.w;
const dh = last.h - first.h;
if (Math.abs(dw) > 5) parts.push(`width ${first.w}${last.w}px`);
if (Math.abs(dh) > 5) parts.push(`height ${first.h}${last.h}px`);
}
// Visibility
if (first && last && first.visible !== last.visible) {
parts.push(last.visible ? "becomes visible" : "becomes hidden");
}
// Final position
if (last && !last.missing) {
parts.push(`ends at (${last.x}, ${last.y}) ${last.w}×${last.h}px`);
}
// Flags
if (flags.length > 0) {
parts.push(`FLAGS: ${flags.join(", ")}`);
}
return parts.join(". ") + ".";
}
// ─── Flag computation ───────────────────────────────────────────────────────
function computeFlags(tw, bboxes, { width, height }) {
const flags = [];
const dur = tw.end - tw.start;
if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate");
const anyOffscreen = bboxes.some(
(b) =>
b.x + b.w <= 0 ||
b.y + b.h <= 0 ||
b.x >= width ||
b.y >= height ||
b.x < -b.w * 0.5 ||
b.y < -b.h * 0.5 ||
b.x + b.w > width + b.w * 0.5 ||
b.y + b.h > height + b.h * 0.5,
);
if (anyOffscreen) flags.push("offscreen");
if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) {
flags.push("invisible");
}
if (dur < 0.2 && tw.props.some((p) => ["y", "x", "opacity", "scale"].includes(p))) {
flags.push("paced-fast");
}
if (dur > 2.0) flags.push("paced-slow");
return flags;
}
function markCollisions(tweens) {
for (let i = 0; i < tweens.length; i++) {
for (let j = i + 1; j < tweens.length; j++) {
const a = tweens[i];
const b = tweens[j];
if (a.end <= b.start || b.end <= a.start) continue;
for (const ba of a.bboxes) {
const bb = b.bboxes.find((x) => Math.abs(x.t - ba.t) < 0.05);
if (!bb) continue;
const overlap = rectOverlapArea(ba, bb);
const aArea = ba.w * ba.h;
if (aArea > 0 && overlap / aArea > 0.3) {
if (!a.flags.includes("collision")) a.flags.push("collision");
if (!b.flags.includes("collision")) b.flags.push("collision");
break;
}
}
}
}
}
function rectOverlapArea(a, b) {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.w, b.x + b.w);
const y2 = Math.min(a.y + a.h, b.y + b.h);
return Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
}
// ─── Composition-level analysis ─────────────────────────────────────────────
function buildTimeline(tweens, duration) {
const cols = 60;
const lines = [];
const secPerCol = duration / cols;
lines.push("Timeline (" + duration.toFixed(1) + "s, each char ≈ " + secPerCol.toFixed(2) + "s):");
lines.push(" " + "0s" + " ".repeat(cols - 8) + duration.toFixed(0) + "s");
lines.push(" " + "┼" + "─".repeat(cols - 1) + "┤");
for (const tw of tweens) {
const startCol = Math.floor(tw.start / secPerCol);
const endCol = Math.min(cols, Math.ceil(tw.end / secPerCol));
const bar =
" ".repeat(startCol) +
"█".repeat(Math.max(1, endCol - startCol)) +
" ".repeat(Math.max(0, cols - endCol));
const label = tw.selector + " " + tw.props.join("+");
lines.push(" " + bar + " " + label);
}
return lines.join("\n");
}
function computeDensity(tweens, duration) {
const buckets = [];
for (let t = 0; t < duration; t += 0.5) {
const active = tweens.filter((tw) => tw.start <= t + 0.5 && tw.end >= t);
buckets.push({ t: +t.toFixed(1), activeTweens: active.length });
}
return buckets;
}
function findDeadZones(density, duration) {
const zones = [];
let zoneStart = null;
for (const d of density) {
if (d.activeTweens === 0) {
if (zoneStart === null) zoneStart = d.t;
} else {
if (zoneStart !== null) {
const zoneEnd = d.t;
if (zoneEnd - zoneStart >= 1.0) {
zones.push({
start: zoneStart,
end: zoneEnd,
duration: +(zoneEnd - zoneStart).toFixed(1),
note:
"No animation for " +
(zoneEnd - zoneStart).toFixed(1) +
"s. Intentional hold or missing entrance?",
});
}
zoneStart = null;
}
}
}
if (zoneStart !== null && duration - zoneStart >= 1.0) {
zones.push({
start: zoneStart,
end: +duration.toFixed(1),
duration: +(duration - zoneStart).toFixed(1),
note:
"No animation for " +
(duration - zoneStart).toFixed(1) +
"s at end. Final hold or missing outro?",
});
}
return zones;
}
function detectStaggers(tweens) {
const groups = [];
const used = new Set();
for (let i = 0; i < tweens.length; i++) {
if (used.has(i)) continue;
const tw = tweens[i];
const group = [tw];
used.add(i);
for (let j = i + 1; j < tweens.length; j++) {
if (used.has(j)) continue;
const other = tweens[j];
const sameProps = tw.props.join(",") === other.props.join(",");
const sameDuration = Math.abs(tw.duration - other.duration) < 0.05;
const closeInTime = other.start - tw.start < tw.duration * 4;
if (sameProps && sameDuration && closeInTime) {
group.push(other);
used.add(j);
}
}
if (group.length >= 3) {
const intervals = [];
for (let k = 1; k < group.length; k++) {
intervals.push(+(group[k].start - group[k - 1].start).toFixed(3));
}
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const maxDrift = Math.max(...intervals.map((iv) => Math.abs(iv - avgInterval)));
const consistent = maxDrift < avgInterval * 0.3;
groups.push({
elements: group.map((g) => g.selector),
props: tw.props,
count: group.length,
intervals,
avgInterval: +avgInterval.toFixed(3),
consistent,
note: consistent
? group.length +
" elements stagger at " +
(avgInterval * 1000).toFixed(0) +
"ms intervals"
: group.length +
" elements stagger with uneven intervals (" +
intervals.map((iv) => (iv * 1000).toFixed(0) + "ms").join(", ") +
")",
});
}
}
return groups;
}
function buildElementLifecycles(tweens) {
const elements = {};
for (const tw of tweens) {
const sel = tw.selector;
if (!elements[sel]) {
elements[sel] = { firstTween: tw.start, lastTween: tw.end, tweenCount: 0, props: new Set() };
}
elements[sel].firstTween = Math.min(elements[sel].firstTween, tw.start);
elements[sel].lastTween = Math.max(elements[sel].lastTween, tw.end);
elements[sel].tweenCount++;
tw.props.forEach((p) => elements[sel].props.add(p));
}
const result = {};
for (const [sel, data] of Object.entries(elements)) {
const lastBbox = findLastBbox(tweens, sel);
result[sel] = {
firstAppears: +data.firstTween.toFixed(3),
lastAnimates: +data.lastTween.toFixed(3),
tweenCount: data.tweenCount,
props: [...data.props],
endsVisible: lastBbox ? lastBbox.opacity > 0.1 && lastBbox.visible : null,
finalPosition: lastBbox
? { x: lastBbox.x, y: lastBbox.y, w: lastBbox.w, h: lastBbox.h }
: null,
};
}
return result;
}
function findLastBbox(tweens, selector) {
for (let i = tweens.length - 1; i >= 0; i--) {
if (tweens[i].selector === selector && tweens[i].bboxes?.length > 0) {
return tweens[i].bboxes[tweens[i].bboxes.length - 1];
}
}
return null;
}
async function captureSnapshots(session, tweens, duration) {
const times = [0, duration * 0.25, duration * 0.5, duration * 0.75, duration - 0.1];
const snapshots = [];
for (const t of times) {
await seekTo(session, t);
const visible = await session.page.evaluate(() => {
const out = [];
const els = document.querySelectorAll("[id]");
for (const el of els) {
const cs = getComputedStyle(el);
if (cs.display === "none") continue;
const opacity = parseFloat(cs.opacity);
if (opacity < 0.01) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) continue;
out.push({
id: el.id,
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height),
opacity: +opacity.toFixed(2),
});
}
return out;
});
const activeTweens = tweens
.filter((tw) => tw.start <= t && tw.end >= t)
.map((tw) => tw.selector);
snapshots.push({
t: +t.toFixed(2),
visibleElements: visible.length,
animatingNow: activeTweens,
elements: visible,
});
}
return snapshots;
}
// ─── Output ─────────────────────────────────────────────────────────────────
function printSummary(report) {
console.log(
`\nAnimation map: ${report.mappedTweens}/${report.totalTweens} tweens (skipped ${report.skippedMicroTweens} micro-tweens)`,
);
const flagCounts = {};
for (const tw of report.tweens) {
for (const f of tw.flags) flagCounts[f] = (flagCounts[f] ?? 0) + 1;
}
if (Object.keys(flagCounts).length > 0) {
for (const [f, n] of Object.entries(flagCounts)) console.log(` ${f}: ${n}`);
}
if (report.staggers?.length > 0) {
console.log(` staggers: ${report.staggers.map((s) => s.note).join("; ")}`);
}
if (report.deadZones?.length > 0) {
console.log(
` dead zones: ${report.deadZones.map((z) => z.start + "-" + z.end + "s").join(", ")}`,
);
}
console.log(report.choreography);
}
function parseArgs(argv) {
const out = {};
let positional = 0;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const k = a.slice(2);
const v = argv[i + 1]?.startsWith("--") ? true : argv[++i];
out[k] = v;
} else if (positional === 0) {
out.composition = a;
positional++;
}
}
return out;
}
function die(msg) {
console.error(`animation-map: ${msg}`);
process.exit(2);
}
@@ -0,0 +1,335 @@
#!/usr/bin/env node
// contrast-report.mjs — HyperFrames contrast audit
//
// Reads a composition, seeks to N sample timestamps, walks the DOM for text
// elements, measures the WCAG 2.1 contrast ratio between each element's
// declared foreground color and the pixels behind it, and emits:
//
// - contrast-report.json (machine-readable, one entry per text element × sample)
// - contrast-overlay.png (sprite grid; magenta=fail AA, yellow=pass AA only, green=AAA)
//
// Usage:
// node skills/hyperframes/scripts/contrast-report.mjs <composition-dir> \
// [--samples N] [--out <dir>] [--width W] [--height H] [--fps N]
//
// The composition directory must contain an index.html. Raw authoring HTML
// works — the producer's file server auto-injects the runtime at serve time.
// Exits 1 if any text element fails WCAG AA.
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import sharp from "sharp";
// Use the producer's file server — it auto-injects the HyperFrames runtime
// and render-seek bridge, so raw authoring HTML works without a build step.
import {
createFileServer,
createCaptureSession,
initializeSession,
closeCaptureSession,
captureFrameToBuffer,
getCompositionDuration,
} from "@hyperframes/producer";
// ─── CLI ─────────────────────────────────────────────────────────────────────
const args = parseArgs(process.argv.slice(2));
if (!args.composition) die("missing <composition-dir>");
const SAMPLES = Number(args.samples ?? 10);
const OUT_DIR = resolve(args.out ?? ".hyperframes/contrast");
const WIDTH = Number(args.width ?? 1920);
const HEIGHT = Number(args.height ?? 1080);
const FPS = Number(args.fps ?? 30);
const COMP_DIR = resolve(args.composition);
// ─── Main ────────────────────────────────────────────────────────────────────
await mkdir(OUT_DIR, { recursive: true });
const server = await createFileServer({ projectDir: COMP_DIR, port: 0 });
const session = await createCaptureSession(
server.url,
OUT_DIR,
{ width: WIDTH, height: HEIGHT, fps: FPS, format: "png" },
null,
);
await initializeSession(session);
try {
const duration = await getCompositionDuration(session);
const times = Array.from(
{ length: SAMPLES },
(_, i) => +(((i + 0.5) / SAMPLES) * duration).toFixed(3),
);
const allEntries = [];
const overlayFrames = [];
for (let i = 0; i < times.length; i++) {
const t = times[i];
const { buffer: pngBuf } = await captureFrameToBuffer(session, i, t);
const elements = await probeTextElements(session, t);
const annotated = await annotateFrame(pngBuf, elements);
overlayFrames.push({ t, png: annotated });
for (const el of elements) allEntries.push({ time: t, ...el });
}
const report = {
composition: COMP_DIR,
width: WIDTH,
height: HEIGHT,
duration,
samples: times,
entries: allEntries,
summary: summarize(allEntries),
};
await writeFile(resolve(OUT_DIR, "contrast-report.json"), JSON.stringify(report, null, 2));
await writeOverlaySprite(overlayFrames, resolve(OUT_DIR, "contrast-overlay.png"));
printSummary(report);
process.exitCode = report.summary.failAA > 0 ? 1 : 0;
} finally {
await closeCaptureSession(session).catch(() => {});
server.close();
}
// ─── DOM probe (runs in the page) ────────────────────────────────────────────
async function probeTextElements(session, _t) {
// `session.page` is the Puppeteer Page owned by the capture session.
// We pass a pure function to `evaluate`: it walks the DOM and returns
// enough info for us to compute a ratio in Node using the frame buffer.
return await session.page.evaluate(() => {
/** @type {Array<{selector: string, text: string, fg: [number,number,number,number], fontSize: number, fontWeight: number, bbox: {x:number,y:number,w:number,h:number}}>} */
const out = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
const parseColor = (c) => {
const m = c.match(/rgba?\(([^)]+)\)/);
if (!m) return [0, 0, 0, 1];
const parts = m[1].split(",").map((s) => parseFloat(s.trim()));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
};
const selectorOf = (el) => {
if (el.id) return `#${el.id}`;
const cls = [...el.classList].slice(0, 2).join(".");
return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase();
};
let el;
while ((el = walker.nextNode())) {
// must have direct text
const direct = [...el.childNodes].some(
(n) => n.nodeType === 3 && n.textContent.trim().length,
);
if (!direct) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) continue;
out.push({
selector: selectorOf(el),
text: el.textContent.trim().slice(0, 60),
fg: parseColor(cs.color),
fontSize: parseFloat(cs.fontSize),
fontWeight: Number(cs.fontWeight) || 400,
bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
});
}
return out;
});
}
// ─── Pixel sampling + WCAG math ──────────────────────────────────────────────
async function annotateFrame(pngBuf, elements) {
const img = sharp(pngBuf);
const meta = await img.metadata();
const { width, height } = meta;
const raw = await img.ensureAlpha().raw().toBuffer();
const channels = 4;
const measured = [];
for (const el of elements) {
const bg = sampleRingMedian(raw, width, height, channels, el.bbox);
const fg = compositeOver(el.fg, bg); // flatten any alpha against measured bg
const ratio = wcagRatio(fg, bg);
const large = isLargeText(el.fontSize, el.fontWeight);
el.bg = bg;
el.ratio = +ratio.toFixed(2);
el.wcagAA = large ? ratio >= 3 : ratio >= 4.5;
el.wcagAALarge = ratio >= 3;
el.wcagAAA = large ? ratio >= 4.5 : ratio >= 7;
measured.push(el);
}
// Draw boxes + ratio labels as an SVG overlay (sharp composite).
const svg = buildOverlaySVG(measured, width, height);
return await sharp(pngBuf)
.composite([{ input: Buffer.from(svg), top: 0, left: 0 }])
.png()
.toBuffer();
}
function sampleRingMedian(raw, width, height, channels, bbox) {
// 4-px ring immediately outside the element bbox. Median of each channel.
const r = [],
g = [],
b = [];
const x0 = Math.max(0, Math.floor(bbox.x) - 4);
const x1 = Math.min(width - 1, Math.ceil(bbox.x + bbox.w) + 4);
const y0 = Math.max(0, Math.floor(bbox.y) - 4);
const y1 = Math.min(height - 1, Math.ceil(bbox.y + bbox.h) + 4);
const pushPixel = (x, y) => {
const i = (y * width + x) * channels;
r.push(raw[i]);
g.push(raw[i + 1]);
b.push(raw[i + 2]);
};
for (let x = x0; x <= x1; x++) {
pushPixel(x, y0);
pushPixel(x, y1);
}
for (let y = y0; y <= y1; y++) {
pushPixel(x0, y);
pushPixel(x1, y);
}
return [median(r), median(g), median(b), 1];
}
function median(arr) {
const s = [...arr].sort((a, b) => a - b);
return s[Math.floor(s.length / 2)];
}
function compositeOver([fr, fg, fb, fa], [br, bg, bb]) {
return [
Math.round(fr * fa + br * (1 - fa)),
Math.round(fg * fa + bg * (1 - fa)),
Math.round(fb * fa + bb * (1 - fa)),
1,
];
}
function relLum([r, g, b]) {
const ch = (v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b);
}
function wcagRatio(a, b) {
const la = relLum(a);
const lb = relLum(b);
const [L1, L2] = la > lb ? [la, lb] : [lb, la];
return (L1 + 0.05) / (L2 + 0.05);
}
function isLargeText(fontSize, fontWeight) {
return fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
}
// ─── Overlay rendering ───────────────────────────────────────────────────────
function buildOverlaySVG(elements, w, h) {
const rects = elements
.map((el) => {
const color = !el.wcagAA ? "#ff00aa" : !el.wcagAAA ? "#ffcc00" : "#00e08a";
const { x, y, w: bw, h: bh } = el.bbox;
return `
<rect x="${x}" y="${y}" width="${bw}" height="${bh}"
fill="none" stroke="${color}" stroke-width="3"/>
<rect x="${x}" y="${y - 18}" width="${48}" height="16" fill="${color}"/>
<text x="${x + 4}" y="${y - 5}" font-family="monospace" font-size="12" fill="#000">
${el.ratio.toFixed(1)}:1
</text>`;
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">${rects}</svg>`;
}
async function writeOverlaySprite(frames, outPath) {
if (!frames.length) return;
const cols = Math.min(frames.length, 5);
const rows = Math.ceil(frames.length / cols);
const { width, height } = await sharp(frames[0].png).metadata();
const scale = 0.25;
const cellW = Math.round(width * scale);
const cellH = Math.round(height * scale);
const cells = await Promise.all(
frames.map(async (f) => ({
input: await sharp(f.png).resize(cellW, cellH).png().toBuffer(),
time: f.t,
})),
);
const composites = cells.map((c, i) => ({
input: c.input,
top: Math.floor(i / cols) * cellH,
left: (i % cols) * cellW,
}));
await sharp({
create: {
width: cols * cellW,
height: rows * cellH,
channels: 3,
background: { r: 16, g: 16, b: 20 },
},
})
.composite(composites)
.png()
.toFile(outPath);
}
// ─── Summary ────────────────────────────────────────────────────────────────
function summarize(entries) {
const total = entries.length;
const failAA = entries.filter((e) => !e.wcagAA).length;
const passAAonly = entries.filter((e) => e.wcagAA && !e.wcagAAA).length;
const passAAA = entries.filter((e) => e.wcagAAA).length;
return { total, failAA, passAAonly, passAAA };
}
function printSummary({ summary, entries }) {
const { total, failAA, passAAonly, passAAA } = summary;
console.log(`\nContrast report: ${total} text-element samples`);
console.log(` fail WCAG AA: ${failAA}`);
console.log(` pass AA, not AAA: ${passAAonly}`);
console.log(` pass AAA: ${passAAA}`);
if (failAA) {
console.log("\nFailures:");
for (const e of entries.filter((x) => !x.wcagAA)) {
console.log(` t=${e.time}s ${e.selector.padEnd(24)} ${e.ratio.toFixed(2)}:1 "${e.text}"`);
}
}
}
// ─── Utilities ──────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {};
let positional = 0;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const k = a.slice(2);
const v = argv[i + 1]?.startsWith("--") ? true : argv[++i];
out[k] = v;
} else if (positional === 0) {
out.composition = a;
positional++;
}
}
return out;
}
function die(msg) {
console.error(`contrast-report: ${msg}`);
process.exit(2);
}
+211
View File
@@ -0,0 +1,211 @@
# Visual Style Library
Named visual identities for HyperFrames videos. Each style is grounded in a real graphic design tradition. Use them to give your video a specific visual personality, not just generic "clean" or "bold."
**How to pick:** Match mood first, content second. Ask: _"What should the viewer FEEL?"_
**How to use:** Reference the style in your scene plan. Translate the style's principles into concrete composition decisions — palette choice, font selection, entrance patterns, transition type, ambient motion feel.
## Quick Reference
| Style | Mood | Best for | Primary shader |
| --------------- | --------------------- | ---------------------------------- | --------------------------------- |
| Swiss Pulse | Clinical, precise | SaaS, data, dev tools, metrics | Cinematic Zoom or SDF Iris |
| Velvet Standard | Premium, timeless | Luxury, enterprise, keynotes | Cross-Warp Morph |
| Deconstructed | Industrial, raw | Tech launches, security, punk | Glitch or Whip Pan |
| Maximalist Type | Loud, kinetic | Big announcements, launches | Ridged Burn |
| Data Drift | Futuristic, immersive | AI, ML, cutting-edge tech | Gravitational Lens or Domain Warp |
| Soft Signal | Intimate, warm | Wellness, personal stories, brand | Thermal Distortion |
| Folk Frequency | Cultural, vivid | Consumer apps, food, communities | Swirl Vortex or Ripple Waves |
| Shadow Cut | Dark, cinematic | Dramatic reveals, security, exposé | Domain Warp |
---
## 1. Swiss Pulse — Josef Müller-Brockmann
**Mood:** Clinical, precise | **Best for:** SaaS dashboards, developer tools, APIs, metrics
- Black (`#1a1a1a`), white, ONE accent — electric blue (`#0066FF`) or amber (`#FFB300`)
- Helvetica or Inter Bold for headlines, Regular for labels. Numbers large (80120px)
- Grid-locked compositions. Every element snaps to an invisible 12-column grid
- Animated counters count up from 0. Hard cuts, no decorative transitions
- Transitions: Cinematic Zoom or SDF Iris (precise, geometric)
**GSAP signature:** `expo.out`, `power4.out`. Entries are fast and snap into place. Nothing floats.
```
Swiss Pulse: Black/white + one electric accent. Grid-locked compositions.
Numbers dominate the frame at 80-120px. Counter animations from 0.
Hard cuts or geometric transitions. Nothing decorative.
```
---
## 2. Velvet Standard — Massimo Vignelli
**Mood:** Premium, timeless | **Best for:** Luxury products, enterprise software, keynotes, investor decks
- Black, white, ONE rich accent — deep navy (`#1a237e`) or gold (`#c9a84c`)
- Thin sans-serif, ALL CAPS, wide letter-spacing (`0.15em+`)
- Generous negative space. Symmetrical, centered, architectural precision
- Slow, deliberate. Sequential reveals with long holds. No frantic motion
- Transitions: Cross-Warp Morph (elegant, organic flow between scenes)
**GSAP signature:** `sine.inOut`, `power1`. Nothing snaps — everything glides with intention.
```
Velvet Standard: Black, white, one rich accent. Thin ALL CAPS type with wide tracking.
Generous negative space. Sequential reveals, long holds.
Cross-Warp Morph transitions. Slow and deliberate — luxury takes its time.
```
---
## 3. Deconstructed — Neville Brody
**Mood:** Industrial, raw | **Best for:** Tech news, developer launches, security products, punk-energy reveals
- Dark grey (`#1a1a1a`), rust orange (`#D4501E`), raw white (`#f0f0f0`)
- Type at angles, overlapping edges, escaping frames. Bold industrial weight
- Gritty textures: scan-line effects, glitch artifacts baked into the design
- Text SLAMS and SHATTERS. Letters scramble then snap to final position
- Transitions: Glitch shader or Whip Pan (breaks the rules, feels aggressive)
**GSAP signature:** `back.out(2.5)`, `steps(8)`, `elastic.out(1.2, 0.4)`. Intentional irregularity.
```
Deconstructed: Dark grey #1a1a1a + rust orange #D4501E. Type at angles, escaping frames.
Scan-line glitch overlays. Text SLAMS and scrambles into place.
Glitch shader transitions. Industrial and raw — nothing should feel polished.
```
---
## 4. Maximalist Type — Paula Scher
**Mood:** Loud, kinetic | **Best for:** Big product launches, milestone announcements, high-energy hype videos
- Bold saturated: red (`#E63946`), yellow (`#FFD60A`), black, white — maximum contrast
- Text IS the visual. Overlapping type layers at different scales and angles, filling 5080% of frame
- Everything is kinetic: slamming, sliding, scaling. 23 second rapid-fire scenes
- Text layered OVER footage — never empty backgrounds
- Transitions: Ridged Burn (explosive, dramatic, impossible to ignore)
**GSAP signature:** `expo.out`, `back.out(1.8)`. Fast arrivals, hard stops.
```
Maximalist Type: Red, yellow, black, white — max contrast. Text IS the visual.
Overlapping at different scales, 50-80% of frame. Everything in motion.
Ridged Burn transitions. No static moments — kinetic energy throughout.
```
---
## 5. Data Drift — Refik Anadol
**Mood:** Futuristic, immersive | **Best for:** AI products, ML platforms, data companies, speculative tech
- Iridescent: deep black (`#0a0a0a`), electric purple (`#7c3aed`), cyan (`#06b6d4`)
- Thin futuristic sans-serif — floating, weightless, minimal
- Fluid morphing compositions. Extreme scale shifts (micro → macro)
- Particles coalesce into numbers. Light traces data paths through the frame
- Transitions: Gravitational Lens or Domain Warp (otherworldly distortion)
**GSAP signature:** `sine.inOut`, `power2.out`. Smooth, continuous, organic. Nothing hard.
```
Data Drift: Deep black #0a0a0a with electric purple #7c3aed and cyan #06b6d4.
Thin futuristic type, minimal text. Particles coalesce into numbers.
Gravitational Lens or Domain Warp transitions. Fluid, immersive, otherworldly.
```
---
## 6. Soft Signal — Stefan Sagmeister
**Mood:** Intimate, warm | **Best for:** Wellness brands, personal stories, lifestyle products, human-centered apps
- Warm amber (`#F5A623`), cream (`#FFF8EC`), dusty rose (`#C4A3A3`), sage green (`#8FAF8C`)
- Handwritten-style or humanist serif fonts. Personal, lowercase, delicate
- Close-up framing feel: single element fills the frame. Nothing feels corporate
- Slow drifts and floats, never snaps. Soft organic motion throughout
- Transitions: Thermal Distortion (warm, flowing, like heat shimmer)
**GSAP signature:** `sine.inOut`, `power1.inOut`. Everything breathes.
```
Soft Signal: Warm amber, cream, dusty rose, sage green. Humanist or handwritten type.
Single elements fill the frame — intimate, never corporate.
Slow drifts and floats throughout. Thermal Distortion transitions.
Nothing should feel hurried or polished.
```
---
## 7. Folk Frequency — Eduardo Terrazas
**Mood:** Cultural, vivid | **Best for:** Consumer apps, food platforms, community products, festive launches
- Vivid folk: hot pink (`#FF1493`), cobalt blue (`#0047AB`), sun yellow (`#FFE000`), emerald (`#009B77`)
- Bold warm rounded type. Pattern and repetition — folk art rhythm and density
- Layered compositions with rich visual texture. Every frame feels handcrafted
- Colorful motion: elements bounce, pop, and spin into place with joy
- Transitions: Swirl Vortex or Ripple Waves (hypnotic, celebratory)
**GSAP signature:** `back.out(1.6)`, `elastic.out(1, 0.5)`. Overshoots feel intentional.
```
Folk Frequency: Hot pink #FF1493, cobalt blue, sun yellow, emerald. Bold rounded type.
Pattern and repetition throughout. Layered, dense, handcrafted feeling.
Swirl Vortex or Ripple Waves transitions. Joyful, celebratory energy.
```
---
## 8. Shadow Cut — Hans Hillmann
**Mood:** Dark, cinematic | **Best for:** Security products, dramatic reveals, investigative content, intense launches
- Near-monochrome: deep blacks (`#0a0a0a`), cold greys (`#3a3a3a`), stark white + blood red (`#C1121F`) or toxic green (`#39FF14`)
- Sharp angular text like film noir title cards. Heavy contrast, no softness
- Heavy shadow — elements emerge from darkness. Reveal is the narrative
- Slow creeping push-ins, dramatic scale reveals, silence before the hit
- Transitions: Domain Warp (dissolves reality itself before revealing the next scene)
**GSAP signature:** `power4.in` for exits, `power3.out` for dramatic reveals. The pause before the hit matters.
```
Shadow Cut: Deep blacks #0a0a0a, cold greys, stark white + one accent (blood red or toxic green).
Sharp angular type, film noir aesthetic. Elements emerge from darkness.
Slow creeping push-ins. Domain Warp transitions. The reveal IS the story.
```
---
## Mood → Style Guide
| If the content feels... | Use... |
| ---------------------------------- | --------------- |
| Data-driven, analytical, technical | Swiss Pulse |
| Premium, enterprise, luxury | Velvet Standard |
| Raw, punk, aggressive, rebellious | Deconstructed |
| Hype, loud, high-energy launch | Maximalist Type |
| AI, ML, speculative, futuristic | Data Drift |
| Human, warm, personal, wellness | Soft Signal |
| Cultural, fun, consumer, festive | Folk Frequency |
| Dark, dramatic, intense, cinematic | Shadow Cut |
---
## Creating Custom Styles
These 8 styles are examples — not constraints. Create your own by:
1. **Name it** after a designer, art movement, or cultural reference
2. **Palette**: 2-3 colors max. Declare explicit hex values
3. **Typography**: One family, two weights. State the role of each
4. **Motion rules**: How fast? Snappy or fluid? Overshoot or precision?
5. **Transition**: Which shader matches the energy?
6. **What NOT to do**: 2-3 explicit anti-patterns for this style
The pattern: **named style → palette → typography → motion rules → transition → avoids.**
@@ -0,0 +1,121 @@
---
name: website-to-hyperframes
description: |
Capture a website and create a HyperFrames video from it. Use when: (1) a user provides a URL and wants a video, (2) someone says "capture this site", "turn this into a video", "make a promo from my site", (3) the user wants a social ad, product tour, or any video based on an existing website, (4) the user shares a link and asks for any kind of video content. Even if the user just pastes a URL — this is the skill to use.
---
# Website to HyperFrames
Capture a website, then produce a professional video from it.
Users say things like:
- "Capture https://... and make me a 25-second product launch video"
- "Turn this website into a 15-second social ad for Instagram"
- "Create a 30-second product tour from https://..."
The workflow has 7 steps. Each produces an artifact that gates the next.
---
## Step 1: Capture & Understand
**Read:** [references/step-1-capture.md](references/step-1-capture.md)
Run the capture, read the extracted data, and build a working summary using the write-down-and-forget method.
**Gate:** Print your site summary (name, top colors, fonts, key assets, one-sentence vibe).
---
## Step 2: Write DESIGN.md
**Read:** [references/step-2-design.md](references/step-2-design.md)
Write a simple brand reference for the captured website. 6 sections, ~90 lines. This is a cheat sheet, not the creative plan — that comes in Step 4.
**Gate:** `DESIGN.md` exists in the project directory.
---
## Step 3: Write SCRIPT
**Read:** [references/step-3-script.md](references/step-3-script.md)
Write the narration script. The story backbone. Scene durations come from the narration, not from guessing.
**Gate:** `SCRIPT.md` exists in the project directory.
---
## Step 4: Write STORYBOARD
**Read:** [references/step-4-storyboard.md](references/step-4-storyboard.md)
Write per-beat creative direction: mood, camera, animations, transitions, assets, depth layers, SFX. This is the creative north star — the document the engineer follows to build each composition.
**Gate:** `STORYBOARD.md` exists with beat-by-beat direction and an asset audit table.
---
## Step 5: Generate VO + Map Timing
**Read:** [references/step-5-vo.md](references/step-5-vo.md)
Generate TTS audio, transcribe for word-level timestamps, and map timestamps to beats. Update STORYBOARD.md with real durations.
**Gate:** `narration.wav` (or .mp3) + `transcript.json` exist. Beat timings in STORYBOARD.md updated.
---
## Step 6: Build Compositions
**Read:** The `/hyperframes` skill (invoke it — every rule matters)
**Read:** [references/step-6-build.md](references/step-6-build.md)
Build each composition following the storyboard. After each one: self-review for layout, asset placement, and animation quality.
**Gate:** Every composition has been self-reviewed. No overlapping elements, no misplaced assets, no static images without motion.
---
## Step 7: Validate & Deliver
**Read:** [references/step-7-validate.md](references/step-7-validate.md)
Lint, validate, preview. Create a HANDOFF.md for multi-session continuity.
**Gate:** `npx hyperframes lint` and `npx hyperframes validate` pass with zero errors.
---
## Quick Reference
### Video Types
| Type | Duration | Beats | Narration |
| --------------------- | -------- | ----- | ---------------------- |
| Social ad (IG/TikTok) | 10-15s | 3-4 | Optional hook sentence |
| Product demo | 30-60s | 5-8 | Full narration |
| Feature announcement | 15-30s | 3-5 | Full narration |
| Brand reel | 20-45s | 4-6 | Optional, music focus |
| Launch teaser | 10-20s | 2-4 | Minimal, high energy |
### Format
- **Landscape**: 1920x1080 (default)
- **Portrait**: 1080x1920 (Instagram Stories, TikTok)
- **Square**: 1080x1080 (Instagram feed)
### Reference Files
| File | When to read |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [step-1-capture.md](references/step-1-capture.md) | Step 1 — reading captured data |
| [step-2-design.md](references/step-2-design.md) | Step 2 — writing DESIGN.md |
| [step-3-script.md](references/step-3-script.md) | Step 3 — writing the narration script |
| [step-4-storyboard.md](references/step-4-storyboard.md) | Step 4 — per-beat creative direction |
| [step-5-vo.md](references/step-5-vo.md) | Step 5 — TTS, transcription, timing |
| [step-6-build.md](references/step-6-build.md) | Step 6 — building compositions with self-review |
| [step-7-validate.md](references/step-7-validate.md) | Step 7 — lint, validate, preview, handoff |
| [techniques.md](references/techniques.md) | Steps 4 & 6 — 10 visual techniques with code patterns (SVG drawing, Canvas 2D, 3D, typography, Lottie, video, typing, variable fonts, MotionPath, transitions) |
@@ -0,0 +1,66 @@
# Step 1: Capture & Understand
## Run the capture
```bash
npx hyperframes capture <URL> -o captures/<project-name>
```
No API keys required. The capture extracts design tokens, screenshots, fonts, and assets with DOM-context descriptions automatically.
**Optional:** Set `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) in a `.env` file at the repo root for richer AI-powered image descriptions via Gemini 2.5 Flash vision. Free tier: 5 RPM; paid tier removes the bottleneck.
Wait for it to complete. Print how many screenshots, assets, sections, and fonts were extracted.
## Read and summarize
Read each file below. After reading each one, **write a 1-2 sentence summary** of what you learned. These summaries are your working memory — the raw file content may be cleared from context later.
### Must read (do not skip)
1. **View the scroll screenshots** — viewport-sized captures covering the full page height (the number depends on the page length). Start with:
- `screenshots/scroll-000.png` — the hero section at full 1920x1080 resolution. This is the most important image. Describe: is the background light or dark? What's the dominant visual element? What colors jump out?
- Then scan through the rest to see the full page. Each screenshot overlaps the previous by ~30%.
After viewing them, write 3-4 sentences describing the site's visual mood, layout patterns, color strategy, and overall feel.
2. **`extracted/tokens.json`** — Note the top 5-7 colors (HEX), all font families, number of sections, and number of headings/CTAs.
3. **`extracted/visible-text.txt`** — Note the site's headline, tagline, key selling points, and any notable statistics or social proof.
4. **`extracted/asset-descriptions.md`** — One-line-per-file summary of all downloaded assets. Note which assets are most visually striking or useful for video (hero images, logos, product screenshots).
### Read if they exist
5. **`extracted/animations.json`** — Note if the site uses scroll-triggered animations, marquees, canvas/WebGL, or named CSS animations.
6. **`extracted/lottie-manifest.json`** — View each preview image at `assets/lottie/previews/` to see what the animations look like.
7. **`extracted/video-manifest.json`** — View each preview at `assets/videos/previews/` to see what each video shows.
8. **`extracted/shaders.json`** — If present, this contains the actual GLSL shader code that powers the site's WebGL visual effects (gradient waves, particle systems, noise fields). Read the fragment shaders to extract: color values used in gradients, noise algorithms, blend functions. You can recreate similar effects in your compositions using Canvas 2D or by embedding the shader patterns with a `<canvas>` + WebGL context. See the Canvas 2D and procedural art patterns in `techniques.md`.
### On-demand (read when building scenes)
9. **Individual images in `assets/`** — Use `asset-descriptions.md` as your index. View specific images when you need them for a beat.
10. **`extracted/assets-catalog.json`** — Use to find remote URLs when you need an asset that wasn't downloaded.
### For rich captures (30+ images)
Launch a sub-agent to view all images and SVGs:
> "Read every image in assets/ and every SVG in assets/svgs/. For each, write one line: filename — what it shows, dominant colors, approximate size. Return the complete catalog."
Use the sub-agent's catalog as your asset reference for the rest of the workflow.
## Gate
Print your site summary before proceeding to Step 2:
- **Site:** [name]
- **Colors:** [top 3-5 HEX values with roles]
- **Fonts:** [font families]
- **Sections:** [count] sections, [count] headings, [count] CTAs
- **Key assets:** [3-5 most useful assets for video]
- **Vibe:** [one sentence describing the visual identity]
@@ -0,0 +1,128 @@
# Step 2: Write DESIGN.md
DESIGN.md is a **brand cheat sheet** for the captured website. It encodes the visual identity so you can reference exact colors, fonts, and patterns while writing the storyboard and compositions.
DESIGN.md is NOT the creative plan. The STORYBOARD (Step 4) drives creative direction. DESIGN.md is a reference you consult, not a document you follow slavishly.
## The 6 Sections
### `## Overview`
3-4 sentences. Describe the visual identity factually: layout patterns (bento grid, logo wall, hero section), color strategy, typography tone, overall feel. Be precise, not poetic.
### `## Colors`
5-10 key colors with HEX values from tokens.json and their roles:
```
- **Primary Surface**: `#020204` — deep black background
- **Primary Content**: `#FFFFFF` — high-purity white for text and borders
- **Accent Warm**: `#FB923C` — orange for CTAs and highlights
```
Include semantic colors if the site uses color to differentiate product areas.
### `## Typography`
Font families with weights, roles, and any distinctive usage:
```
- **Serif**: Cormorant Garamond (Italic). Major headings, brand identity.
- **Monospace**: Geist Mono. Subheaders, labels, terminal readouts. High tracking (0.1-0.3em), all-caps.
- **Sans-Serif**: Inter. Body copy, interface elements. Small sizes (9-14px).
```
Include sizing hierarchy if notable (hero: 64px, section: 32px, body: 16px).
### `## Elevation`
One paragraph on depth strategy: Does the site use borders, shadows, glassmorphism, or flat color shifts? Reference specific patterns (e.g., "1px borders at white/10 opacity" or "layered backdrop-blur with thin borders").
### `## Components`
Name every notable UI component you see in the screenshot. Be specific:
- "Cinematic Accordion" not "Cards"
- "Logo Marquee" not "Scrolling section"
- "Glass Cards with grain overlay" not "Content containers"
For each, note the distinctive visual treatment (border-radius, spacing, hover behavior).
### `## Do's and Don'ts`
3-5 rules each, derived from what the site actually does and doesn't do:
```
### Do's
- Use thin subtle borders (white/10) to separate sections
- Keep imagery desaturated with dark gradients for text readability
### Don'ts
- Do not use bright solid background colors — stay in "The Void"
- Do not use standard drop shadows — use radial glow or bloom effects
- Do not use sharp high-speed animations — all motion should be fluid
```
## Rules
- Use **exact HEX values** from tokens.json. Do not approximate.
- Name components by what you see in the screenshot, not generic terms.
- Keep it under 100 lines. This is a cheat sheet, not a design system document.
- No "Style Prompt" section — the storyboard handles creative direction.
- No "Assets" section — `asset-descriptions.md` already covers this.
- No "Motion" section — the storyboard specifies motion per-beat.
## Example
This is a real DESIGN.md from a production capture (Soulscape 2026):
```markdown
# Design System
## Overview
Soulscape 2026 is a cinematic, "high-signal" digital experience that positions itself as the vanguard of AI filmmaking. The visual personality is dark, technical, and premium, characterized by high-contrast "Flare" on "Void" (white on black) aesthetics. The layout is dense but organized, utilizing heavy horizontal layering and border-defined sections to evoke a wide-screen cinematic feel. Motion is a core tenet, with atmospheric grain overlays, shifting light leaks, and slow-moving marquees creating constant, breathing texture.
## Colors
- **Primary Surface**: `#020204` (Void) - Deep black for the entire background.
- **Primary Content**: `#FFFFFF` (Flare) - High-purity white for typography and primary borders.
- **Accent 1 (Warm)**: `#FB923C` - Orange for industry/executive tiers and primary CTAs.
- **Accent 2 (Cool)**: `#60A5FA` - Blue for creative voices and summit-focused components.
- **Subtle Overlays**: `rgba(255, 255, 255, 0.02)` to `0.08` for glass backgrounds.
## Typography
- **Serif**: Cormorant Garamond (Italic). Major headings and "Soul" brand identity. Classical cinematic contrast.
- **Monospace**: Geist Mono. Subheaders, labels, terminal readouts. High tracking (0.1-0.3em), all-caps.
- **Sans-Serif**: Inter. Body copy and interface elements. Small sizes (9-14px).
## Elevation
- **Glassmorphism**: Components use backdrop-filter blur(10px) with thin borders (1px solid rgba(255, 255, 255, 0.08)).
- **Layering**: Depth via fixed global grain-overlay and localized light-leak gradients rather than box-shadows.
- **Interaction**: Hover triggers subtle translateY(-5px) and increased border opacity.
## Components
- **Cinematic Accordion**: Expanding horizontal/vertical card system where panels expand from compressed state to reveal full-bleed imagery and large serif typography.
- **HUD Explorer**: Floating mobile navigation trigger styled as a "Lens" with pulsing glow and terminal readouts.
- **Slow Marquees**: Continuous horizontal tickers for partner logos and veteran listings.
- **Glass Cards**: Content containers with subtle gradients, rounded corners (2.5rem), and high-contrast iconography.
- **Grain & Flicker**: Global CSS noise filters and holographic flicker animations on UI labels.
## Do's and Don'ts
### Do's
- Use thin subtle borders (white/10) to separate sections rather than solid color changes.
- Maintain high letter-spacing on all Geist Mono labels.
- Use serif italics for emotional or visionary statements.
- Keep imagery desaturated or stylized with dark gradients for readability.
### Don'ts
- Do not use bright solid background colors — the page must remain in "The Void."
- Do not use standard drop shadows — use radial glow or bloom effects instead.
- Do not use sharp high-speed animations — all motion should be fluid and breathing.
```
@@ -0,0 +1,96 @@
# Step 3: Write the Narration Script
**Before writing, re-read DESIGN.md** — specifically the Overview and Components sections. The script should reference real product features, real stats, and real components that the website highlights. Use exact numbers from `extracted/visible-text.txt`.
The script is the backbone. Everything downstream — scene durations, animation timing, beat pacing — comes from the narration. Write it before the storyboard.
Save as `SCRIPT.md` in the project directory.
## Pacing
- **2.5 words per second** is natural speaking pace
- 15s = ~37 words. 30s = ~75 words. 60s = ~150 words
- Leave room for pauses. Silence between sentences is a feature, not dead air
- The script should feel SHORTER than the video — visual breathing room matters
## Tone
Write like a person, not a brochure:
- Use contractions: "it's", "you'll", "that's", "we've"
- Vary sentence length — short punchy phrases mixed with longer flowing ones
- Read it out loud. If it sounds robotic, rewrite it
- Avoid jargon unless the audience expects it
## Number Pronunciation
Write what you want the voice to say. TTS reads literally.
| On the website | Write in script as |
| -------------- | --------------------------------- |
| 135+ | more than one hundred thirty five |
| $1.9T | nearly two trillion dollars |
| 99.999% | ninety nine point nine percent |
| 200M+ | over two hundred million |
| 10x | ten times |
| API | A P I |
| stripe.com | stripe dot com |
The visual can show the exact figure while the voice rounds it.
## Structure
For product videos from a website capture:
1. **Hook** — what's surprising or impressive about this product? A bold claim, a provocative question, a contrast, or a striking number. This is the opening line. **Vary the hook type** — don't default to a stat every time.
2. **Story** — what does the product do? Who uses it? Keep it concrete.
3. **Proof** — stats, customer names, social proof. Real numbers from the website.
4. **CTA** — what should the viewer do? "Start building at stripe dot com."
Not every video needs all four. A 15-second social ad might be Hook + Proof + CTA. A 60-second product tour uses all four with more Story.
## The Opening Line
The most important sentence in the video. It must create tension, curiosity, or surprise in the first 3 seconds.
Patterns that work:
- **A bold claim**: "The financial infrastructure that powers the internet economy."
- **A question that provokes**: "What if your database could think?"
- **A contrast**: "Your AI agent already knows how to make videos. It just needs the right format."
- **A number that shocks**: "Nearly two trillion dollars." (Use sparingly — not every video should open with a stat.)
If the opening is generic ("Welcome to Stripe" / "Introducing our product"), start over.
## Example
From a 62-second product launch video (team reference):
```
Your AI agent already knows how to make videos.
It just needs the right format.
This is Hyperframes. An open source framework. HTML in, video out.
A div is a keyframe. Data attributes are your timeline.
CSS is your look. G-Sap is your animation engine.
Anything a browser can render can be a frame in your video.
CSS animations. G-Sap. Lottie. Shaders. Three.js.
Drop in music, sound effects, footage — it all composes together.
No new framework for the agent to learn.
Just HTML.
The agent writes it. The renderer captures every frame as MP4.
It's deterministic. Identical outputs, every time.
Give your agent the CLI. Tell it what to make.
Watch it build.
Hyperframes. Go make something.
```
Note: ~140 words for 62 seconds — that's 2.3 words/sec, leaving room for pauses and visual breathing.
@@ -0,0 +1,222 @@
# Step 4: Write the Storyboard
**Before writing anything, fully re-read these files:**
- **DESIGN.md** — your color palette, font rules, components, Do's/Don'ts. Every creative decision must be grounded in this brand identity. If it says "white backgrounds with purple accent" — plan light scenes, not dark moody ones.
- **`extracted/asset-descriptions.md`** — read EVERY line. This is your menu of available visuals. Each line describes what the image actually shows (e.g., "translucent ribbons in orange, pink, and purple on white background" or "a high-speed train under a dark starry sky"). Use these descriptions to decide which assets belong in which beat. Assets you don't understand from the description — view them directly before assigning.
- **[techniques.md](techniques.md)** — 10 visual techniques (SVG path drawing, Canvas 2D art, CSS 3D, per-word typography, Lottie, video compositing, typing effect, variable fonts, MotionPath, velocity transitions). Pick 2-3 per beat and specify them in the storyboard.
The storyboard is the creative north star. It tells the engineer exactly what to build for each beat — mood, camera, animations, transitions, assets, sound. Write it as if you're briefing a motion designer who's never seen the website.
Save as `STORYBOARD.md` in the project directory.
---
## Global Direction
Every STORYBOARD.md starts with global settings:
```markdown
**Format:** 1920×1080
**Audio:** [TTS provider] voiceover + underscore + SFX
**VO direction:** [voice character — e.g., "mid-age male, calm confident delivery,
Apple keynote register — economy of words, silence between sentences is a feature"]
**Style basis:** DESIGN.md (brand colors, fonts, components from the captured site)
```
**Global guardrails** (adapt to the brand):
- Push color presence. Muted is fine, flat is not. Every beat should have at least one color that pulls your eye.
- Motion should be visible and intentional. Err toward more movement than feels safe — subtle reads as static at 30fps.
- Use as many captured assets as the creative vision allows. Scatter framework icons around a dashboard. Layer enterprise photos behind stats. Use product screenshots as floating cards. The assets exist — use them generously.
- Aim for 8-10 visual elements per beat, not 2-3. A great beat has: background texture, midground content, foreground accents, floating decorative elements, animated icons, SVG path drawings, particle effects, typographic details. It should feel DENSE and alive.
- Use at least 2-3 different techniques from techniques.md per beat — not across the whole video, per beat. Don't default to basic fade/scale/opacity — mix in SVG path drawing, CSS 3D transforms, typing effects, counter animations, canvas procedural art. Each beat should feel like its own visual world.
**Underscore/music direction** (if applicable):
- Describe the mood, reference artists, when it swells or drops
- Example: "Minimal electronic. Warm sustained pad already playing when the video starts. Sits underneath everything, never competing with VO. Swells gently during the flex section, drops to near-nothing for the comparison, resolves on a final chord."
---
## Asset Audit
Before writing any beats, audit every captured asset. Print this table:
| Asset | Type | Assign to Beat | Role |
| ------------------------------ | ---------- | -------------- | ------------------------------------- |
| wave-fallback-desktop.png | Hero image | Beat 1 | Full-bleed animated background |
| enterprise-accordion-hertz.png | Photo | Beat 3 | Enterprise credibility, Ken Burns pan |
| stripe-logo.svg | SVG | Beat 1, Beat 5 | Brand mark opener + closer |
| datavizstatic3x.png | Data viz | Beat 3 | Supporting visual behind stats |
| icon-3.svg | Icon | SKIP | Decorative, too small |
**Minimum utilization:**
- At least 50% of product screenshots and hero images must appear
- Brand logo appears in the first AND last beat
- The site's signature visual (gradient wave, hero illustration, key product UI) must appear — it's the most recognizable brand element
- Maximum 2 consecutive text-only beats. The 3rd must contain a visual asset
- Opening beat must contain a visual asset, not text-only
---
## Per-Beat Direction
Each beat is a WORLD, not a layout. Before writing CSS specs and GSAP instructions, describe what the viewer EXPERIENCES. The difference between a great storyboard and a mediocre one:
**Mediocre:** "Dark navy background. '$1.9T' in white, 280px. Logo top-left. Wave image bottom-right."
**Great:** "Camera is already mid-flight over a vast dark canvas. The gradient wave sweeps across the frame like aurora borealis — alive, shifting. '$1.9T' SLAMS into existence with such force the wave ripples in response. This isn't a slide — it's a moment."
The first describes pixels. The second describes an experience. Write the second, then figure out the pixels.
Each beat should have:
### Concept
The big idea for this beat in 2-3 sentences. What visual WORLD are we in? What metaphor drives it? What should the viewer FEEL? This is the most important part — everything else flows from it.
### VO cue
Which narration line plays over this beat.
### Visual description
What the viewer sees — described cinematically, not as CSS specs. Use camera language (pan, zoom, drift, settle). Describe at least 5 visual elements, not just text + background. Think in layers — what's moving in the foreground, midground, background simultaneously?
### Mood direction
Cultural and design references, not hex codes:
- "Geometric, rhythmic, precise. Think Josef Albers or Bauhaus color studies."
- "Warm workspace. Nice notebook energy, not technical blueprint."
- "Cinematic title sequence. The kind of opening where you lean forward."
### Assets
Which captured files to use, referenced by filename:
- "Background: `assets/wave-fallback-desktop.png` — full-bleed, slow zoom 1→1.04 over beat duration"
- "Logo: `assets/svgs/stripe-logo.svg` — centered, fades in at 0.5s"
- "Enterprise photo: `assets/enterprise-accordion-hertz.png` — Ken Burns pan, 70% opacity overlay"
### Animation choreography
Specific motion verbs per element — not "it animates in" but HOW:
| Energy | Verbs | Example |
| ------------- | --------------------------------------------- | ------------------------------------- |
| High impact | SLAMS, CRASHES, PUNCHES, STAMPS, SHATTERS | "$1.9T" SLAMS in from left at -5° |
| Medium energy | CASCADE, SLIDES, DROPS, FILLS, DRAWS | Three cards CASCADE in staggered 0.3s |
| Low energy | types on, FLOATS, morphs, COUNTS UP, fades in | Counter COUNTS UP from 0 to 135K |
Every element gets a verb. If you can't name the verb, the element is not yet designed.
### Transition
How this beat hands off to the next. Specify the type and parameters.
**CSS transitions** (choose from `skills/hyperframes/references/transitions/catalog.md`):
- Velocity-matched upward: exit `y:-150, blur:30px, 0.33s power2.in` → entry `y:150→0, blur:30px→0, 1.0s power2.out`
- Whip pan: exit `x:-400, blur:24px, 0.3s power3.in` → entry `x:400→0, blur:24px→0, 0.3s power3.out`
- Blur through: exit `blur:20px, 0.3s` → entry `blur:20px→0, 0.25s power3.out`
- Zoom through: exit `scale:1→1.2, blur:20px, 0.2s power3.in` → entry `scale:0.75→1, blur:20px→0, 0.5s expo.out`
- Hard cut / smash cut (for rapid-fire sequences)
**Shader transitions** (choose from `skills/hyperframes/references/transitions/shader-transitions.md`):
- Cross-Warp Morph (organic, versatile) — 0.5-0.8s, power2.inOut
- Cinematic Zoom (professional momentum) — 0.4-0.6s, power2.inOut
- Gravitational Lens (otherworldly) — 0.6-1.0s, power2.inOut
- Glitch (aggressive, high energy) — 0.3-0.5s
- See `skills/hyperframes/references/transitions/shader-setup.md` for the full WebGL boilerplate
**How velocity-matched CSS transitions work:**
Exit the outgoing beat with an accelerating ease (power2.in or power3.in) plus a blur ramp. Enter the incoming beat with a decelerating ease (power2.out or power3.out) plus blur clear. The fastest point of both easing curves meets at the cut — the viewer perceives continuous camera motion, not two discrete animations. Match exit velocity to entry velocity within ~5% tolerance.
### Depth layers
What's in foreground, midground, and background. Every beat should have at least 2 layers:
- "BG: dark navy fill + subtle radial glow. MG: stat cards with drop shadow. FG: brand logo bottom-right."
### SFX cues
What sounds at what moment:
- "On the capture pulse — a soft, warm analog shutter click."
- "Left side carries a faint low drone. On fold: drone cuts. Silence. Then a single clean chime."
---
## Production Architecture
Include this file tree at the bottom of the storyboard:
```
project/
├── index.html root — VO + underscore + beat orchestration
├── DESIGN.md brand reference (from Step 2)
├── SCRIPT.md narration text (from Step 3)
├── STORYBOARD.md THIS FILE — creative north star
├── transcript.json word-level timestamps (from Step 5)
├── narration.wav TTS audio (from Step 5)
├── captures/<name>/ captured website data
└── compositions/
├── beat-1-hook.html
├── beat-2-features.html
├── ...
└── captions.html
```
---
## Example: Beat-by-Beat Format
Here are three beats from a production storyboard showing the level of detail expected.
### BEAT 1 — COLD OPEN (0:000:05)
**VO:** "Your AI agent already knows how to make videos."
**Concept:** We're already in motion when the video starts. No title card, no fade from black. We're mid-flight over an infinite creative workspace — dozens of living compositions scattered below us like a city seen from a drone. Each one is alive, running a different animation. The message is clear before any words: this tool makes videos. Lots of them.
**Visual:** Slow smooth diagonal drift over a vast canvas (3600×2200px plane). Scattered across it: 25 composition cards at organic angles (±5-15° rotation), soft shadows, thin borders. Each card contains a DIFFERENT running animation — kinetic type, gradient morph, data viz, particle system, logo assembly, SVG drawing, shader noise, 3D rotating object. Depth-of-field: close cards slightly blurred, focal sweet-spot in mid-distance, far cards smaller and desaturated.
**Camera:** Diagonal drift top-left to bottom-right, slight 2-3° rotation over 5s. power1.inOut ease. Zoom accelerates in final second as we approach one specific card.
**Assets:** Product screenshots and logo on cards. Each card is a mini-composition with its own animation.
**SFX:** Ambient warmth pad already playing. Faint textured hum — overhearing creative activity from a distance.
---
### BEAT 5 — THE THESIS (0:200:24)
**VO:** "Anything a browser can render can be a frame in your video."
**Mood:** Big statement. This sentence gets its own canvas. Clean, spacious, typographic.
**Visual:** Words appear as staggered kinetic typography. "Anything a browser can render" — distinctive serif, gentle fade + rise (y: 24px → 0, opacity 0 → 1, 0.4s, power2.out). Held beat — one second of stillness. "can be a frame in your video." appears below. As the final word lands, the entire text pulses once — a brief warm flash, subtle scale bump to 101%.
**Transition OUT:** Whip pan left — x:-400, blur:24px, opacity:0.4, 0.3s power3.in
**SFX:** Silence under the first line. On the capture pulse — a soft analog shutter click.
---
### BEAT 7 — THE CONTRAST (0:380:44)
**VO:** "No new framework for the agent to learn. Just HTML."
**Mood:** Clean comparison. Light base. Two worlds side by side.
**Visual:** Left half: dense code, small, compressed, overwhelming. Scrolls slowly upward. Slightly desaturated. Right half: spacious HTML, syntax-highlighted, generous line spacing, inviting. On "Just HTML." — the left side folds inward along its center line, like a book closing. The right side expands to fill the frame. Warm glow rises behind it.
**Transition IN:** Zoom through — scale 0.75→1, blur 20px→0, 0.5s expo.out
**Transition OUT:** Velocity-matched upward — y:-150, blur:30px, 0.33s power2.in
**Assets:** Real framework code on the left (actual content, not lorem ipsum). Real HyperFrames HTML on the right.
**SFX:** Left side carries a faint low drone. On fold: drone cuts. Silence. Then a single clean chime as the right side expands.
@@ -0,0 +1,40 @@
# Step 5: Generate VO + Map Timing
## Audition voices
Never use the first voice you find. Audition 2-3 voices with the first sentence of SCRIPT.md:
- **Kokoro** (try first — free, no API key) — `npx hyperframes tts SCRIPT.md --voice af_nova --output narration.wav`. Runs locally on CPU. Requires Python 3.10+ (macOS system Python 3.9 won't work — if it fails with an onnxruntime error, move to the next option).
- **ElevenLabs** (best voice quality, widest selection) — `mcp__elevenlabs__search_voices` to browse, `mcp__elevenlabs__text_to_speech` to generate. Does not return timestamps — transcribe separately after.
- **HeyGen TTS** (returns word timestamps automatically — saves a transcribe step) — `mcp__claude_ai_HeyGen__text_to_speech`. Use when you want timestamps without a separate transcription pass.
Pick the voice that sounds most natural and conversational. Listen for pacing — does it breathe between sentences? Does it sound like a person or a robot?
## Generate full narration
Generate the full script as `narration.wav` (or `.mp3`) in the project directory.
## Transcribe for word-level timestamps
```bash
npx hyperframes transcribe narration.wav
```
Produces `transcript.json` with `[{ text, start, end }]` for every word. These timestamps are the source of truth for all beat durations.
## Map timestamps to beats
Go through STORYBOARD.md beat by beat. For each beat:
1. Find the first word of that beat's VO cue in `transcript.json`
2. Find the last word of that beat's VO cue
3. Set `beat.start = firstWord.start`, `beat.end = lastWord.end`
4. Add 0.3-0.5s padding at the end for visual breathing room
Update STORYBOARD.md with real durations. Replace estimated times (e.g., "0:00-0:05") with actual timestamps (e.g., "0.00-3.21s").
Beat boundaries land on word onsets — hard cuts to the VO.
## Update index.html
Update each scene slot's `data-start` and `data-duration` to match the real beat timings from the transcript. Also update the total composition duration and audio element duration.
@@ -0,0 +1,162 @@
# Step 6: Build Compositions
**Before building, fully re-read these files:**
- **DESIGN.md** — your color palette, fonts, components, and Do's/Don'ts. Every composition must use EXACT hex colors and font families from this file. If it says "white backgrounds" — use white, not dark.
- **STORYBOARD.md** — the beat-by-beat plan you're executing. Each beat specifies assets, animations, transitions, and which techniques to use.
- **`extracted/asset-descriptions.md`** — when the storyboard assigns an asset to a beat, re-read the description to understand what it shows and how to position/style it correctly.
- **[techniques.md](techniques.md)** — code patterns for the 10 visual techniques. When the storyboard says "SVG path drawing" or "per-word kinetic typography" — read the code pattern from this file and adapt it.
- **transcript.json** — word-level timestamps that drive scene durations.
**Split the work: spawn a sub-agent for each beat.** By this step your context is full of captured data, DESIGN.md, SCRIPT, STORYBOARD, and transcript. Building compositions on top of all that means the detailed rules below compete with thousands of tokens of prior work. Each sub-agent gets a fresh context focused on one beat — dramatically better output.
**How to dispatch each sub-agent:**
Pass file PATHS, not file contents. The #1 failure mode is reading an asset file and pasting its SVG/image data into the sub-agent prompt. The sub-agent then uses inline content instead of referencing the file on disk. Same with fonts — pass the local woff2 path, don't substitute Google Fonts.
```
Build the composition for beat 1. Save to compositions/beat-1-hook.html.
STORYBOARD for this beat:
[paste the beat section from STORYBOARD.md]
ASSETS — reference by path, do NOT read/inline the file contents:
- Logo: <img src="../assets/favicon.svg"> (top-left, 40x40px)
- Hero image: <img src="../assets/hero-bg.png"> (full-bleed background)
- Noise texture: ../assets/noise.png (full-frame overlay, 3% opacity)
FONTS — use @font-face with the captured font files, NOT Google Fonts:
@font-face { font-family: 'BrandFont'; src: url('../assets/fonts/BrandFont-Regular.woff2'); }
Read DESIGN.md for exact colors and Do's/Don'ts.
Read techniques.md for animation code patterns.
Invoke /hyperframes for composition structure rules.
```
After each sub-agent finishes, verify the composition references `../assets/` — if it used inline SVGs or Google Fonts instead of the captured files, fix it before moving on.
Invoke the `/hyperframes` skill first — it has the rules for data attributes, timeline contracts, deterministic rendering, and layout. Everything below supplements those rules, not replaces them.
---
## Per-Composition Process
For each beat in the storyboard:
### 1. Read the beat's storyboard section
Know the mood, visual description, assets, animation choreography, transition, and SFX before writing any HTML.
### 2. Build the static end-state first
Position every element where it should be at its **most visible moment** — the frame where everything is fully entered and correctly placed. Write this as static HTML+CSS. No GSAP yet.
This is the "Layout Before Animation" principle from the compose skill. The CSS position is the ground truth. Animations describe the journey to and from it.
### 3. Verify the static layout
Look at it. Check:
- Are elements where the storyboard says they should be?
- Are depth layers present (foreground / midground / background)?
- Do any elements overlap unintentionally?
- Are assets sized correctly? (hero images should fill 50-70% of frame, not sit at 100x100px)
### 4. Add entrance animations
Use `gsap.from()` — animate FROM offscreen/invisible TO the CSS position. The CSS position is where the element ends up.
### 5. Add mid-scene activity
Every visible element must have continuous motion. A still image on a still background is a JPEG with a progress bar.
| Element type | Mid-scene activity |
| ---------------------- | ------------------------------------------------ |
| Image / screenshot | Slow zoom (scale 1→1.03), slow pan, or Ken Burns |
| Stat / number | Counter animates from 0 to target |
| Logo grid | Subtle shimmer sweep, or gentle scale pulse |
| Any persistent element | Subtle float (y ±4-6px, sine.inOut, yoyo) |
### 6. Add exit / transition
Check the storyboard's transition specification for this beat:
- **CSS transition**: implement the exit animation (e.g., `y:-150, blur:30px, 0.33s power2.in`). The next composition handles its own entry.
- **Shader transition**: no exit animation needed — the shader handles the blend. Read `skills/hyperframes/references/transitions/shader-setup.md` for the full WebGL boilerplate and `skills/hyperframes/references/transitions/shader-transitions.md` for the fragment shader. Copy the FULL boilerplate — a simplified version produces black screens.
- **Hard cut**: no exit animation. The scene simply ends.
For all CSS transition types and their GSAP implementations, read `skills/hyperframes/references/transitions/catalog.md`.
### 7. Asset cross-reference
Before self-review, verify you actually used the assets you planned to:
1. Open STORYBOARD.md and find this beat's asset assignments
2. List every asset that was assigned to this beat
3. Search the composition HTML for each filename (e.g., grep for "wave-fallback-desktop")
4. If any assigned asset is missing from the HTML, add it now
5. Check for the inline anti-pattern: if the HTML contains `<svg xmlns=` or `data:image/` but no `../assets/` references, the assets were inlined instead of referenced. Replace inline content with `<img src="../assets/filename.svg">`
6. Check fonts: if the HTML uses `fonts.googleapis.com` but there are captured fonts in `assets/fonts/`, replace with `@font-face` pointing to the local files
This step catches the two most common failures: compositions ending up text-only, and assets being inlined instead of file-referenced.
### 8. Self-review
After building the composition, check WITH ACTUAL CODE:
- [ ] Asset cross-reference passed (step 7 above — every assigned asset is in the HTML)
- [ ] Elements are where the storyboard says they should be (no misplacement)
- [ ] No overlapping text (text covering text is always ugly)
- [ ] Depth layers present (2+ layers minimum)
- [ ] Every visible element has mid-scene activity (not just entrance + exit)
- [ ] Font sizes above minimum (20px body text, 16px labels — sub-14px is unreadable after encoding)
- [ ] No full-screen dark linear gradients (H.264 creates visible banding — use solid + localized radial glows)
- [ ] Timeline registered: `window.__timelines["comp-id"] = tl`
- [ ] Colors match DESIGN.md exactly (paste the HEX value, don't approximate)
**If `skills/hyperframes-animation-map/` is installed**, run it:
```bash
node skills/hyperframes-animation-map/scripts/animation-map.mjs <composition-dir>
```
Read the summaries. Fix every flag: offscreen, collision, invisible, pacing issues.
### 9. Move to the next composition
---
## Asset Presentation
Never embed a raw flat image. Every image must have motion treatment:
- **Perspective tilt**: use `gsap.set(el, { transformPerspective: 1200, rotationY: -8 })` + `box-shadow` — creates depth. Do NOT use CSS `transform: perspective(...)` as GSAP will overwrite it.
- **Slow zoom (Ken Burns)**: GSAP `scale: 1``1.04` over beat duration — makes photos cinematic
- **Device frame**: Wrap in a laptop/phone shape using CSS `border-radius` and `box-shadow`
- **Floating UI**: Extract a key element and animate it at a different z-depth for parallax
- **Scroll reveal**: Clip the image to a viewport window and animate `y` position
---
## Audio Wiring
In the root `index.html`:
- **Narration**: `<audio id="narration" src="narration.wav" data-start="0" data-duration="..." data-track-index="0" data-volume="1">`
- **Underscore/music** (if storyboard specifies): `<audio id="underscore" src="underscore.mp3" data-start="0" data-duration="..." data-track-index="3" data-volume="0.15">`
- **SFX** (if storyboard specifies): individual `<audio>` elements at specific `data-start` timestamps
- **Captions** (optional — only if user requests): sub-composition on a parallel track. Skip unless explicitly asked for.
---
## Critical Rules
These exist because the capture engine is deterministic. Violations produce broken output.
- **No `repeat: -1`** — calculate exact repeats from beat duration
- **No `Math.random()`** — use a seeded PRNG (mulberry32)
- **Register every timeline**: `window.__timelines["comp-id"] = tl`
- **Synchronous timeline construction** — no async/await wrapping timeline code
- **Never use ANY CSS `transform` for centering** — not `translate(-50%, -50%)`, not `translateX(-50%)`, not `translateY(-50%)`. GSAP animates the `transform` property, which overwrites ALL CSS transforms including centering. The element flies offscreen. Use flexbox centering instead: `display:flex; align-items:center; justify-content:center` on a wrapper div. The linter catches this (`gsap_css_transform_conflict`) but only if you run it.
- **Minimum font sizes**: 20px body, 16px labels
- **No full-screen dark linear gradients** — H.264 banding
@@ -0,0 +1,109 @@
# Step 7: Validate & Deliver
## Lint + Validate
Run in sequence. Fix all errors before proceeding to the next command.
```bash
npx hyperframes lint
npx hyperframes validate
```
`lint` checks HTML structure statically — missing attributes, timeline registration, tween conflicts, CSS transform + GSAP conflicts (including inline styles).
`validate` loads the composition in headless Chrome and catches runtime JS errors, missing assets, and failed network requests.
## Visual Verification (snapshot)
After lint and validate pass, capture snapshot frames to SEE your own output:
```bash
npx hyperframes snapshot <project-dir> --at <beat-midpoints>
```
If the snapshot command isn't available, fall back to:
```bash
npx tsx packages/cli/src/cli.ts snapshot <project-dir> --at <beat-midpoints>
```
Calculate the midpoint of each beat from your STORYBOARD.md timings. For a 4-beat video with beats at 0-5.8s, 5.8-15.0s, 15.0-22.5s, 22.5-25.3s:
```bash
npx hyperframes snapshot <project-dir> --at 2.9,10.4,18.7,23.9
```
This renders one frame per beat at the moment when content is most visible. Use timestamps where the most content is on screen — usually 60-70% into each beat, after entrances finish but before exits start.
**View every snapshot image carefully.** Don't glance and move on. For each frame, check:
**Visibility:**
- Is there visible content? All-white or all-black frames mean compositions aren't rendering.
- Can you read ALL text? White text on white/light background is invisible. Dark text on dark background is invisible. Every text element needs contrast against what's directly behind it.
- Are images and assets showing? Empty space where an image should be means a path issue or missing file.
**Positioning and layout:**
- Do background images fill the entire frame? If an image only covers half the screen, the `object-fit`, `width`, `height`, or position values are wrong.
- Are elements where the storyboard says they should be? Compare the snapshot to the beat description.
- Is there too much empty/dead space? If more than 40% of the frame is a flat solid color with nothing on it, the composition is sparse.
- Are elements overlapping incorrectly? Text over text, or content bleeding off the edges?
**Visual quality:**
- Are overlays too heavy? If a background image is barely visible through a dark overlay, reduce the overlay opacity.
- Is the visual hierarchy clear? One dominant element per frame, supporting elements secondary.
- Do the colors match DESIGN.md? Check actual rendered colors against what was planned.
**Code vs. rendered verification:**
- For each beat, check: does the snapshot show the assets you referenced in the HTML? If a composition has `<img src="...wave.png">` but the snapshot shows no wave — the image isn't loading, the path is wrong, or it's hidden behind another element.
- If a snapshot shows nothing at a timestamp, try a slightly different time (1-2 seconds later). Compositions may still be in entrance animations.
- The snapshot command is fast — run it multiple times at different timestamps if needed.
If any frame has issues, go back to Step 6 and fix that composition before proceeding.
## Preview
```bash
npx hyperframes preview
```
Open the studio in a browser. Scrub through every beat.
## Create HANDOFF.md
Write a `HANDOFF.md` for multi-session continuity:
```markdown
# Handoff — [Project Name]
**Date:** [today]
**Preview:** `npx hyperframes preview`
## What's Built
| Beat | File | Dur | Status | Notes |
| ---- | -------------------- | ---- | ------ | ----- |
| 1 | beat-1-hook.html | 5.2s | Built | ... |
| 2 | beat-2-features.html | 6.8s | Built | ... |
## Audio
| Asset | Status | Notes |
| --------------- | ------ | ------------------------------------ |
| narration.wav | Done | [provider], [voice name], [duration] |
| transcript.json | Done | [word count] words, [duration] |
## What Needs Work
- [any known issues, polish requests, missing SFX]
## Commands
npx hyperframes preview
npx hyperframes lint
npx hyperframes validate
npx hyperframes snapshot <project-dir> --at <beat-midpoints>
npx hyperframes render --output renders/final.mp4
```
@@ -0,0 +1,341 @@
# Visual Techniques Reference
10 proven techniques from production HyperFrames videos. Use these in your storyboard and compositions to create visually rich, professional output. Each technique includes a minimal code pattern you can adapt.
These are NOT advanced — they're standard motion design patterns that every composition should use at least 2-3 of.
---
## 1. SVG Path Drawing
A path draws itself in real-time, like someone tracing with a pen. Use for revealing diagrams, arrows, connector lines, or brand marks.
```html
<svg viewBox="0 0 400 200">
<path
class="draw-path"
d="M 50 100 L 200 50 L 350 100"
stroke="#c84f1c"
stroke-width="4"
fill="none"
stroke-linecap="round"
/>
</svg>
<style>
.draw-path {
stroke-dasharray: 280;
stroke-dashoffset: 280;
}
</style>
<script>
tl.to(".draw-path", { strokeDashoffset: 0, duration: 0.7, ease: "power2.out" }, 0.5);
</script>
```
Use `path.getTotalLength()` to calculate the dasharray value dynamically.
---
## 2. Canvas 2D Procedural Art
Animated noise, particle fields, data visualizations — anything that evolves frame-by-frame. Drive it with a GSAP proxy.
```html
<canvas id="proc-canvas" width="1920" height="1080"></canvas>
<script>
var canvas = document.getElementById("proc-canvas");
var ctx = canvas.getContext("2d");
function hash(x, y) {
var n = x * 374761393 + y * 668265263;
n = (n ^ (n >> 13)) * 1274126177;
return ((n ^ (n >> 16)) & 0x7fffffff) / 0x7fffffff;
}
function drawFrame(t) {
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, 1920, 1080);
for (var i = 0; i < 200; i++) {
var x = hash(i, 0) * 1920;
var y = hash(i, 1) * 1080;
var brightness = hash(i, Math.floor(t * 10)) * 255;
ctx.fillStyle = "rgba(255, 255, 255, " + brightness / 255 + ")";
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
}
var proxy = { time: 0 };
tl.to(
proxy,
{
time: 5,
duration: 5,
ease: "none",
onUpdate: function () {
drawFrame(proxy.time);
},
},
0,
);
</script>
```
The `hash()` function is deterministic — same frame renders identically every time.
---
## 3. CSS 3D Transforms
Perspective rotations create depth. Use for product showcases, card flips, architectural reveals.
```html
<div class="stage" style="perspective: 900px;">
<div class="card-3d" style="transform-style: preserve-3d;">
<div class="face front">Product</div>
<div class="face back" style="transform: rotateY(180deg);">Details</div>
</div>
</div>
<script>
tl.to(".card-3d", { rotationY: 360, rotationX: 15, duration: 1.2, ease: "sine.inOut" }, 0);
</script>
```
Always set `perspective` on the parent, `transform-style: preserve-3d` on the animated element.
---
## 4. Per-Word Kinetic Typography
Words appear one-by-one, synced to transcript.json timestamps. The core technique for narration-driven videos.
```html
<div class="headline">
<span class="word w-0">Anything</span>
<span class="word w-1">a</span>
<span class="word w-2">browser</span>
<span class="word w-3">can</span>
<span class="word w-4">render</span>
</div>
<style>
.word {
display: inline-block;
opacity: 0;
margin: 0 0.12em;
}
</style>
<script>
// Word onset times from transcript.json (seconds relative to beat start)
var timings = [0.0, 0.23, 0.28, 0.63, 0.78];
var slides = [80, 60, 50, 25, 12]; // horizontal slide decay (px)
document.querySelectorAll(".word").forEach(function (word, i) {
tl.from(
word,
{
x: slides[i],
y: 14,
opacity: 0,
duration: 0.35,
ease: "power2.out",
},
timings[i],
);
});
</script>
```
The slide distance DECAYS per word (80→12px) — mimics a camera settling.
---
## 5. Lottie Animation
Vector animations that play inside a composition. Use for logos, character animations, icons.
```html
<script src="https://cdn.jsdelivr.net/npm/@dotlottie/player-component@2.7.12/dist/dotlottie-player.js"></script>
<dotlottie-player
class="lottie"
src="../assets/lottie/animation-0.json"
autoplay
loop
speed="1.5"
style="width:500px;height:500px;"
>
</dotlottie-player>
<script>
gsap.set(".lottie", { scale: 0.3, opacity: 0 });
tl.to(".lottie", { scale: 1, opacity: 1, duration: 0.35, ease: "back.out(1.6)" }, 0.2);
</script>
```
Or use lottie-web for more control:
```javascript
var anim = lottie.loadAnimation({
container: document.getElementById("anim"),
renderer: "svg",
loop: false,
autoplay: false,
path: "../assets/lottie/animation-0.json",
});
```
---
## 6. Video Compositing
Embed real video footage inside compositions. Videos must be `muted` with `playsinline`.
```html
<div class="video-frame" style="width:680px;height:840px;border-radius:16px;overflow:hidden;">
<video
id="footage"
src="../assets/videos/clip.mp4"
muted
playsinline
style="width:100%;height:100%;object-fit:cover;"
></video>
</div>
<script>
// Video playback is controlled by the framework — don't call play() manually
tl.from(".video-frame", { scale: 0.9, opacity: 0, duration: 0.3, ease: "power2.out" }, 0);
</script>
```
The HyperFrames runtime handles video seeking and playback.
---
## 7. Character-by-Character Typing
Terminal typing effect using `tl.call()` to update text content character by character.
```html
<div class="terminal-line">
<span class="prompt"></span>
<span class="typed" id="typed-text"></span>
<span class="cursor" style="width:11px;height:22px;background:#333;display:inline-block;"></span>
</div>
<script>
var CMD = "npx hyperframes init";
var typed = document.getElementById("typed-text");
// Cursor blinks
tl.to(".cursor", { opacity: 0, duration: 0.12, yoyo: true, repeat: 20, ease: "steps(1)" }, 0);
// Type each character
for (var i = 0; i < CMD.length; i++) {
(function (idx) {
tl.call(
function () {
typed.textContent = CMD.substring(0, idx + 1);
},
null,
(idx / CMD.length) * 0.9,
);
})(i);
}
</script>
```
Use `ease: "steps(1)"` for cursor blink — creates discrete on/off.
---
## 8. Variable Font Axis Animation
Animate font-variation-settings to reshape glyphs in real-time. Works with variable fonts that have axes like optical size (opsz), weight (wght), softness (SOFT).
```html
<style>
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,100..900&display=block");
.wordmark {
--opsz: 144;
--wght: 440;
font-family: "Fraunces", serif;
font-variation-settings:
"opsz" var(--opsz),
"wght" var(--wght);
font-size: 200px;
}
</style>
<script>
tl.to(".wordmark", { "--opsz": 72, "--wght": 300, duration: 0.45, ease: "power2.out" }, 0);
</script>
```
The glyph subtly reshapes as axes animate — optical size adjusts detail, weight changes thickness.
---
## 9. GSAP MotionPathPlugin
Animate an element along an arbitrary SVG path. Use for sliders following curves, particles along trajectories, guided reveals.
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js"></script>
<div class="dot" style="width:20px;height:20px;background:#2a8a7c;border-radius:50%;"></div>
<script>
gsap.registerPlugin(MotionPathPlugin);
tl.to(
".dot",
{
motionPath: { path: "M 12 300 C 280 280 520 80 820 50 S 1200 48 1308 38" },
duration: 1.5,
ease: "power2.out",
},
0,
);
</script>
```
---
## 10. Velocity-Matched Transitions
Exit one beat and enter the next with matched velocities — creates perceived continuous motion.
```javascript
// EXIT (in outgoing composition): accelerating with blur
tl.to(
".content",
{
y: -150,
filter: "blur(30px)",
opacity: 0,
duration: 0.33,
ease: "power2.in", // accelerates
},
beatDuration - 0.33,
);
// ENTRY (in incoming composition): decelerating from blur
gsap.set(".content", { y: 150, filter: "blur(30px)" });
tl.to(
".content",
{
y: 0,
filter: "blur(0px)",
duration: 1.0,
ease: "power2.out", // decelerates
},
0,
);
```
The fastest point of both curves meets at the cut — the viewer perceives smooth camera motion. Match ease families: `.in` for exits, `.out` for entries.
---
## When to Use What
| Video energy | Techniques to combine |
| ------------------------------ | --------------------------------------------------------------- |
| High impact (launches, promos) | Per-word typography + velocity transitions + counter animations |
| Cinematic (tours, stories) | SVG path drawing + video compositing + 3D transforms |
| Technical (dev tools, APIs) | Character typing + Canvas 2D procedural + MotionPath |
| Premium (luxury, enterprise) | Variable font animation + Lottie + slow velocity transitions |
| Data-driven (stats, metrics) | Canvas 2D procedural + counter animations + SVG path drawing |