diff --git a/.claude/skills/captions/SKILL.md b/.claude/skills/captions/SKILL.md
deleted file mode 100644
index ac4c3c37a6..0000000000
--- a/.claude/skills/captions/SKILL.md
+++ /dev/null
@@ -1,291 +0,0 @@
----
-name: captions
-description: Build timed caption compositions from whisper.cpp transcripts in HyperFrames. Use when adding captions, subtitles, or timed text to a composition that has a transcript.json file.
----
-
-# Captions
-
-Build HyperFrames caption compositions from whisper.cpp transcript data.
-
-## When to Use
-
-- Project has a `transcript.json` (output from whisper.cpp `--output-json`)
-- User asks to "add captions", "add subtitles", or "show the words"
-- Editing an existing captions composition
-
-## Whisper.cpp Transcript Format
-
-The transcript file is whisper.cpp's native JSON output. Word-level timestamps are in the `tokens` array within each segment:
-
-```json
-{
- "transcription": [
- {
- "timestamps": { "from": "00:00:00,000", "to": "00:00:05,000" },
- "offsets": { "from": 0, "to": 5000 },
- "text": " Hello world.",
- "tokens": [
- {
- "text": " Hello",
- "offsets": { "from": 0, "to": 1000 },
- "p": 0.98
- },
- {
- "text": " world",
- "offsets": { "from": 1000, "to": 2000 },
- "p": 0.95
- }
- ]
- }
- ]
-}
-```
-
-Key fields per token:
-
-- `text` — word text (often has leading space for word-initial tokens)
-- `offsets.from` / `offsets.to` — milliseconds
-- `p` — confidence (0-1)
-
-### Normalizing to a Word Array
-
-Flatten the transcript into a simple word array for grouping:
-
-```js
-const words = [];
-for (const segment of transcript.transcription) {
- for (const token of segment.tokens || []) {
- const text = token.text.trim();
- if (!text) continue;
- words.push({
- text: text,
- start: token.offsets.from / 1000,
- end: token.offsets.to / 1000,
- });
- }
-}
-```
-
-## Word Grouping
-
-Group words into caption lines. Never show more than 5 words at once. Break on:
-
-1. **Sentence boundaries** — `.` `?` `!`
-2. **Pauses** — gap > 150ms between consecutive words
-3. **Max word count** — 3-5 words per group (3 for portrait, 5 for landscape)
-
-```js
-function groupTranscript(words, maxWords) {
- const groups = [];
- let current = [];
-
- words.forEach((word, i) => {
- current.push(word);
-
- const next = words[i + 1];
- const isPause = next && next.start - word.end > 0.15;
- const isSentenceEnd = /[.?!]/.test(word.text);
-
- if (current.length >= maxWords || isPause || isSentenceEnd || !next) {
- groups.push({
- text: current.map((w) => w.text).join(" "),
- start: current[0].start,
- end: current[current.length - 1].end,
- });
- current = [];
- }
- });
- return groups;
-}
-```
-
-## Composition Structure
-
-Caption compositions follow the standard HyperFrames composition format:
-
-```html
-
-
-
-```
-
-## Positioning
-
-- **Landscape (1920x1080):** Bottom third — `bottom: 80-120px`, centered horizontally
-- **Portrait (1080x1920):** Lower middle — `bottom: 600-700px`, centered horizontally
-- Use `position: absolute` on caption groups, stacked in the same container
-- Set `pointer-events: none` on the composition so captions don't block clicks
-- **Never use relative positioning for captions.** Relative positioning creates a single long line that overflows off screen. Caption groups must be absolutely positioned so hidden words don't affect layout.
-- **Avoid covering the subject's face.** Position captions in the lower third or upper portion, away from the speaker.
-- **Maintain adequate padding** from screen edges (80px minimum on sides)
-
-## Animation Patterns
-
-### Fade + slide (clean, modern)
-
-```js
-tl.fromTo(el, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.4, ease: "expo.out" }, start);
-tl.to(el, { opacity: 0, y: -10, duration: 0.3, ease: "expo.out" }, end - 0.3);
-```
-
-### Pop-in bounce (playful)
-
-```js
-tl.to(el, { autoAlpha: 1, scale: 1.1, duration: 0.2, ease: "power2.out" }, start);
-tl.to(el, { scale: 1, duration: 0.1, ease: "power2.inOut" }, start + 0.2);
-tl.to(el, { autoAlpha: 0, scale: 0, duration: 0.15, ease: "power2.in" }, end);
-```
-
-### Hard cut (no animation)
-
-```js
-tl.set(el, { display: "flex" }, start);
-tl.set(el, { display: "none" }, end);
-```
-
-## Dynamic Font Sizing
-
-If a caption group is too wide for the viewport, scale the font size down:
-
-```js
-const maxWidth = viewportWidth - 160; // 80px padding each side
-const el = document.createElement("div");
-el.className = "caption-group";
-el.textContent = group.text;
-el.style.fontSize = baseFontSize + "px";
-container.appendChild(el);
-
-// Measure and scale if needed
-const measured = el.getBoundingClientRect().width;
-if (measured > maxWidth) {
- const scale = maxWidth / measured;
- el.style.fontSize = Math.floor(baseFontSize * scale) + "px";
-}
-```
-
-Scale stroke width and spacing proportionally when scaling font size.
-
-## Active Word Highlighting
-
-Highlight the currently spoken word within a caption group by changing its color:
-
-```js
-// Create individual word spans within the group
-const wordSpans = group.words.map((w) => {
- const span = document.createElement("span");
- span.textContent = w.text + " ";
- span.style.color = defaultColor;
- el.appendChild(span);
- return { span, start: w.start, end: w.end };
-});
-
-// Animate each word's color at its start time
-wordSpans.forEach((ws) => {
- tl.to(ws.span, { color: accentColor, duration: 0.05 }, ws.start);
- tl.to(ws.span, { color: defaultColor, duration: 0.05 }, ws.end);
-});
-```
-
-## Timing Rules
-
-- **Entrance animation must complete before exit starts.** If a group is shorter than entrance + exit duration, reduce animation durations.
-- **Exit before next group enters.** Use `Math.min(group.end, nextGroup.start)` as the hide time.
-- **Minimum display time:** 0.3s — skip groups shorter than this.
-- **No overlapping caption groups.** Only one group visible at a time.
-- **Sync to actual audio.** Captions must match word-level timestamps from the transcript, not estimated timing.
-
-## Constraints
-
-- **Deterministic.** No `Math.random()`, no `Date.now()`.
-- **Register timeline.** `window.__timelines["captions"] = tl;`
-- **IIFE scope.** Wrap script in `(function() { ... })()` to avoid globals.
-- **Scoped CSS.** Prefix all selectors with `[data-composition-id="captions"]`.
-- **Set `data-duration`.** Must match or exceed the last caption's end time.
-- **Do not animate `visibility` or `display` on timed clips.** Use `opacity` or `autoAlpha`.
-- **Do not use `tl.set()` for property changes that need to survive timeline seeking.** Use `tl.to()` with a short duration instead.
-- **Keep it simple.** Simpler captions perform better than fancy ones. Avoid excessive effects.
-
-## Common Mistakes
-
-- Captions not synced to actual audio (using estimated timing instead of transcript)
-- Multiple caption groups visible simultaneously — only ONE group at a time
-- Text too small to read on mobile devices
-- Insufficient contrast between text and video background
-- Captions covering the subject's face
-- Using relative positioning (creates overflowing single line)
-- All words the same size and style (no visual hierarchy)
-- Overusing animation effects (less is more)
-
-## Loading in the Root Composition
-
-```html
-
-```
diff --git a/.claude/skills/compose-video/SKILL.md b/.claude/skills/compose-video/SKILL.md
deleted file mode 100644
index dcff77af1c..0000000000
--- a/.claude/skills/compose-video/SKILL.md
+++ /dev/null
@@ -1,556 +0,0 @@
----
-name: compose-video
-description: Create HyperFrames HTML video compositions. Use when asked to create a video, build an animation, make a composition, add a title card, or generate any HTML-based video content for HyperFrames.
----
-
-# Compose Video
-
-Create HyperFrames HTML compositions — the HTML files that become videos. This skill contains everything you need to generate valid compositions that work in the studio preview and the producer render pipeline.
-
-## When to Use
-
-- "Create a video about..."
-- "Make a composition with..."
-- "Build an animation that..."
-- "Add a title card / lower third / overlay"
-- "Wrap this video in a produced composition"
-- Any task that produces an `index.html` for HyperFrames
-
-## How HyperFrames Works
-
-HTML is the source of truth for video. A composition is an HTML file where:
-
-- **HTML elements** with `data-*` attributes define what appears and when
-- **CSS** controls positioning and appearance
-- **GSAP timeline** drives animations
-- **The framework** automatically handles clip visibility, media playback, and timeline sync
-
-You write the HTML. The framework does the rest.
-
-### Framework-Managed Behavior
-
-The framework reads data attributes and automatically manages:
-
-- **Primitive clip timeline entries** — the framework reads `data-start`, `data-duration`, and `data-track-index` from primitive clips and adds them to the composition's GSAP timeline. You do not manually add primitive clips to the timeline in scripts.
-- **Media playback** (play, pause, seek) for `` and ``
-- **Clip lifecycle** — clips are **mounted** (made visible on screen) and **unmounted** (removed from screen) based on `data-start` and `data-duration`
-- **Timeline synchronization** (keeping media in sync with the GSAP master timeline)
-- **Media loading** — the framework waits for all media elements to load before resolving timing and starting playback
-
-Mounting and unmounting controls **presence**, not appearance. A clip that is mounted is on screen; a clip that is unmounted is not. Transitions (fade in, slide in, etc.) are separate — they are animated in scripts and happen _after_ a clip is mounted or _before_ it is unmounted.
-
-The framework does **not** handle transitions, effects, or visual animation — those are driven by GSAP in JavaScript.
-
-Do not manually call `video.play()`, `video.pause()`, set `audio.currentTime`, or mount/unmount clips in scripts. The framework owns media playback and clip lifecycle.
-
-## Viewport
-
-Every composition must include `data-width` and `data-height` so scripts and CSS can reference concrete pixel dimensions for layout.
-
-| Format | Width | Height |
-| --------- | ----- | ------ |
-| Landscape | 1920 | 1080 |
-| Portrait | 1080 | 1920 |
-
-## Compositions
-
-A composition is the fundamental grouping unit. Every clip — video, image, audio — must live inside a composition. The `index.html` file is itself a composition (the top-level one), and it can contain nested compositions. Any composition can be imported into another as a sub-composition — there is no special "root" type.
-
-A composition carries the same core attributes as any other clip (`id`, `data-start`, `data-track-index`, `data-duration`), so it can be placed and timed on a timeline just like a video or image.
-
-**Critical Rule: Every top-level HTML container MUST be a composition (i.e., have a `data-composition-id` attribute). You cannot create a top-level HTML container without an associated composition. All visual content must live inside a composition with data attributes to appear in the timeline.**
-
-### Composition File Structure
-
-Each composition should be defined in its own HTML file. This keeps compositions modular, reusable, and maintainable.
-
-```
-project/
-├── index.html # Root composition
-├── compositions/
-│ ├── intro-anim.html # Intro animation composition
-│ ├── caption-overlay.html # Caption composition
-│ └── outro-title.html # Outro composition
-```
-
-**Composition file format** (`compositions/intro-anim.html`):
-
-```html
-
-
-
Welcome!
-
Let's get started
-
-
-
-
-
-
-
-```
-
-**GSAP must be included in every composition.** Each composition file must include `` before any script that uses GSAP. Compositions are loaded independently, so they cannot rely on a parent document having loaded GSAP.
-
-The composition root inside the template must include `data-width` and `data-height`.
-
-### Loading Compositions
-
-Use `data-composition-src` to load a composition from an external HTML file:
-
-```html
-
-```
-
-The framework will:
-
-1. Fetch the HTML file specified in `data-composition-src`
-2. Extract the `` content
-3. Clone and mount it into the composition element
-4. Execute any `
-
-```
-
-## 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
-
-
-
-```
-
-```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
-
-
My Video Title
-
-```
-
-```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
-...
-...
-...
-```
-
-## Top-Level Composition Example
-
-```html
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-## Rendering
-
-Compositions render to MP4 via the producer. The producer loads the HTML in headless Chrome, seeks frame-by-frame, screenshots each frame, and encodes to H.264 with FFmpeg. Preview and render use the same runtime — WYSIWYG.
-
-## Output Checklist
-
-- [ ] Every top-level HTML container is a composition (`data-composition-id` required)
-- [ ] Every composition has `data-width` and `data-height` attributes
-- [ ] Each composition has a `data-duration` attribute
-- [ ] NEVER use empty tweens like `tl.to({}, { duration: N })` — use `data-duration` instead
-- [ ] Each reusable composition is in its own HTML file (in `compositions/` directory)
-- [ ] Compositions loaded via `data-composition-src` attribute
-- [ ] Each composition file uses `` tag to wrap its content
-- [ ] `window.__timelines` given all compositions' timelines
-- [ ] ALL code is 100% deterministic — no `Math.random()`, `Date.now()`, or any randomness
-- [ ] Every composition includes its own GSAP script tag: ``
diff --git a/.claude/skills/media/SKILL.md b/.claude/skills/media/SKILL.md
deleted file mode 100644
index 97fb72fbf6..0000000000
--- a/.claude/skills/media/SKILL.md
+++ /dev/null
@@ -1,299 +0,0 @@
----
-name: media
-description: Video, audio, and image handling constraints for HyperFrames. Use when adding media elements to a composition or troubleshooting media playback.
----
-
-# Media
-
-Constraints for working with video, audio, and images in HyperFrames compositions.
-
-## When to Use
-
-- Adding video, audio, or image clips to a composition
-- Troubleshooting media that won't play or render
-- Working with transparent video, trimming, or volume control
-
-## Video
-
-### Required Attributes
-
-Every `` element must have:
-
-```html
-
-```
-
-- **`muted`** — required. Video elements must not carry audio. Use a separate `` element.
-- **`playsinline`** — required. Prevents fullscreen on mobile.
-- **`crossorigin="anonymous"`** — required for CORS-safe rendering in headless Chrome.
-
-### Supported Codecs
-
-The renderer uses headless Chrome. Only web-compatible codecs work:
-
-| Codec | Container | Works |
-| ---------- | --------- | --------------------- |
-| H.264 | MP4 | Yes |
-| VP8 | WebM | Yes |
-| VP9 | WebM | Yes (including alpha) |
-| AV1 | MP4/WebM | Yes |
-| HEVC/H.265 | MP4 | No |
-| ProRes | MOV | No |
-
-If the source video uses an unsupported codec, transcode first:
-
-```bash
-ffmpeg -i input.mov -c:v libx264 -preset fast -crf 18 -c:a aac -b:a 192k output.mp4
-```
-
-### Transparent Video
-
-Use WebM VP9 with alpha channel for overlays (talking heads, animated elements):
-
-```html
-
-```
-
-Encode transparent video:
-
-```bash
-ffmpeg -i input.mov -c:v libvpx-vp9 -pix_fmt yuva420p -b:v 2M output.webm
-```
-
-### Trimming
-
-Use `data-media-start` to set an in-point offset into the source file:
-
-```html
-
-
-```
-
-The framework seeks the video to the `data-media-start` position and plays from there.
-
-### Video Duration
-
-If `data-duration` is omitted on a video element, the framework uses the source media's intrinsic duration. For predictable timing, always set `data-duration` explicitly.
-
-## Audio
-
-### Separate Audio Element
-
-Audio must always be a separate `` element, even if the audio source is the same file as a video:
-
-```html
-
-
-
-
-
-```
-
-### Volume Control
-
-Use `data-volume` (0-1) to set playback volume:
-
-```html
-
-```
-
-### Background Music
-
-Background music should run for the entire video duration at a lower volume than the main audio:
-
-- **Volume:** 30-50% relative to voice (`data-volume="0.3"` to `data-volume="0.5"`)
-- **Style:** Instrumental only — no vocals or lyrics that compete with speech
-- **Timing:** Start at beginning, loop or extend to cover entire video
-- **Track:** Use a dedicated track index separate from voice and SFX
-
-```html
-
-```
-
-### Sound Effects
-
-- **Duration:** Short — around 0.5 seconds
-- **Volume:** Lower than main audio (30-50% relative to voice)
-- **Placement:** At visual transitions (scene changes, zoom cuts, text reveals)
-- **Frequency:** 1-2 per video at key moments — don't overuse
-
-```html
-
-```
-
-### Audio Trimming
-
-Same as video — use `data-media-start`:
-
-```html
-
-```
-
-## Images
-
-```html
-
-```
-
-- `data-duration` is required on images (no intrinsic duration)
-- Use `object-fit: cover` for full-bleed backgrounds
-- Use `z-index` for layering with video
-
-## Picture-in-Picture
-
-To animate video position/size, wrap it in a non-timed div. Animate the wrapper, not the video:
-
-```html
-
-
-
-```
-
-```js
-// Shrink to corner at 10s
-tl.to(
- "#pip-frame",
- {
- top: 700,
- left: 1360,
- width: 500,
- height: 280,
- borderRadius: 16,
- duration: 1,
- ease: "power2.inOut",
- },
- 10,
-);
-```
-
-**Key rules:**
-
-- The wrapper div has **no** `data-start`, `data-duration`, or `data-track-index`
-- The video inside has all the data attributes
-- Animate the wrapper's CSS properties (top, left, width, height)
-- Never animate the video element's dimensions directly
-
-## Framework Owns Playback
-
-The framework manages all media playback. Never do any of these in scripts:
-
-```js
-// ALL OF THESE ARE WRONG
-video.play();
-video.pause();
-video.currentTime = 5;
-audio.play();
-audio.pause();
-audio.volume = 0.5; // use data-volume attribute instead
-```
-
-The framework:
-
-- Plays/pauses media in sync with the master timeline
-- Seeks media when the timeline is scrubbed
-- Waits for media to load before resolving timing
-
-## Constraints
-
-- **Video must be `muted`.** Audio comes from `` elements.
-- **Video must be `playsinline`.** Required for headless rendering.
-- **Do not nest video inside a timed div.** Video must be a direct child of the composition, or inside a non-timed wrapper.
-- **Do not animate video element dimensions.** Animate a wrapper div instead.
-- **Do not call play/pause/seek on media.** Framework owns playback.
-- **Set `data-duration` explicitly** on all clips for predictable timing.
-- **Use web-compatible codecs.** H.264, VP8, VP9, AV1 only.
-- **Separate audio from video.** Always use `` for sound.
diff --git a/.claude/skills/social-media/SKILL.md b/.claude/skills/social-media/SKILL.md
deleted file mode 100644
index 62f466a0f1..0000000000
--- a/.claude/skills/social-media/SKILL.md
+++ /dev/null
@@ -1,226 +0,0 @@
----
-name: social-media
-description: Create engaging TikTok/Instagram/YouTube Shorts vertical videos with hooks, captions, emojis, and effects in HyperFrames. Use when building social media video content or optimizing compositions for short-form vertical platforms.
----
-
-# Social Media Video
-
-Guidelines for creating engaging short-form vertical videos (TikTok, Instagram Reels, YouTube Shorts) in HyperFrames.
-
-## When to Use
-
-- Building a vertical (9:16) video for social platforms
-- User asks for "TikTok style", "Reels", "Shorts", or "social media video"
-- Adding hooks, emojis, or zoom cuts to a composition
-
-## Viewport
-
-Portrait format: `data-width="1080" data-height="1920"`
-
-Account for platform UI safe areas:
-
-- **Top:** 120px (status bar, platform header)
-- **Bottom:** 200px (platform controls, description)
-- **Right edge:** 80px (like/comment/share buttons on TikTok)
-
-## Hook Section (First 3 Seconds)
-
-The hook grabs attention immediately. It's a separate composition that plays before captions start.
-
-### Text Strategy
-
-- **3-5 words** — short and punchy
-- **Highlight 1-2 key words** with accent color or different size
-- Extract the key question or provocative phrase from the opening script
-
-### Layout
-
-- Split text across multiple lines with slight rotation and horizontal offset
-- Mix small and large text for size contrast and visual hierarchy
-- Center the hook as a unit on screen
-
-### Typography
-
-- Bold, heavy fonts (Inter Bold, Montserrat ExtraBold)
-- Lowercase for casual, approachable feel
-- High contrast colors (white with heavy black stroke, or black on colored background)
-- Bright accent colors for emphasis (lime green, yellow, bright pink)
-
-### Styles
-
-**Bubble style:** Rounded background behind each word/phrase
-
-```css
-.hook-word {
- background: #ffffff;
- color: #000000;
- padding: 12px 24px;
- border-radius: 12px;
- font-weight: 800;
-}
-.hook-word.accent {
- background: #c8ff00; /* lime accent */
-}
-```
-
-**Outlined style:** Heavy stroke, no background
-
-```css
-.hook-word {
- color: #ffffff;
- -webkit-text-stroke: 4px #000000;
- paint-order: stroke fill;
- font-weight: 900;
-}
-```
-
-### Animation
-
-- **Display:** Static and fully visible during hook period (no entrance animation)
-- **Exit:** Quick exit in the last 250-350ms — slide off, zoom + fade, or scale up
-
-```js
-// Hook exit at 2.7s (hook lasts 3s)
-tl.to(
- ".hook-text",
- {
- x: -1080,
- duration: 0.3,
- ease: "power3.in",
- },
- 2.7,
-);
-```
-
-## Captions
-
-See the `captions` skill for full details. Social media specific additions:
-
-- **Start after hook exits** — don't overlap hook and captions
-- **Style consistency** — use the same font family and style (bubble or outlined) as the hook
-- **2-4 words per group** for mobile readability (smaller than landscape)
-- **Active word highlight** — change the current word's color to the accent color
-- **Dynamic sizing** — scale font down if caption group is too wide for 1080px minus padding
-
-## Emojis
-
-Triggered by keywords in the transcript. Appear near (but not overlapping) the subject's face.
-
-### Rules
-
-- **One emoji visible at a time**
-- **~1 second gap** between emoji appearances
-- **3-4 emojis per video** — moderate use
-- **Large enough to notice**, consistent size across all emojis
-- **Thematically relevant** to the spoken word that triggers them
-
-### Timing
-
-```js
-// Trigger emoji when keyword is spoken
-const emojiTriggers = [
- { emoji: "🔥", keyword: "amazing", time: 5.2 },
- { emoji: "💡", keyword: "idea", time: 8.7 },
- { emoji: "🚀", keyword: "launch", time: 12.1 },
-];
-
-emojiTriggers.forEach(({ emoji, time }, i) => {
- const el = document.createElement("div");
- el.className = "emoji-overlay";
- el.textContent = emoji;
- el.style.cssText = `position:absolute;font-size:80px;opacity:0;`;
- // Vary positions: upper-right, left-mid, lower-right, upper-left
- const positions = [
- { top: "15%", right: "10%" },
- { top: "40%", left: "8%" },
- { top: "65%", right: "12%" },
- { top: "20%", left: "10%" },
- ];
- const pos = positions[i % positions.length];
- Object.assign(el.style, pos);
- container.appendChild(el);
-
- // Bounce in
- tl.fromTo(
- el,
- { opacity: 0, scale: 0 },
- { opacity: 1, scale: 1, duration: 0.3, ease: "back.out(1.7)" },
- time,
- );
- // Hold for 2-3s then fade out
- tl.to(el, { opacity: 0, duration: 0.3 }, time + 2.5);
-});
-```
-
-## Zoom Cuts
-
-Add visual emphasis during key moments by cutting to a zoomed-in framing.
-
-### Pattern
-
-```js
-// Zoom in: instant cut (no gradual zoom)
-tl.to(
- "#pip-frame",
- {
- scale: 1.5,
- duration: 0.01, // instant
- },
- keyMomentStart,
-);
-
-// Hold zoomed for duration of emphasized phrase
-
-// Zoom out: smooth animated return
-tl.to(
- "#pip-frame",
- {
- scale: 1,
- duration: 0.5,
- ease: "power2.out",
- },
- keyMomentEnd,
-);
-```
-
-- **Trigger** at important sentences or phrases
-- **Zoom in** is an instant cut, not a gradual zoom
-- **Frame** the subject's face with comfortable padding
-- **Zoom out** is a smooth animated return
-
-## Z-Index Layering
-
-```
-z-index: 1 — base video
-z-index: 10 — effects / overlays
-z-index: 20 — captions
-z-index: 30 — emojis
-z-index: 40 — hook text
-```
-
-## Workflow
-
-1. Transcribe audio with word-level timestamps
-2. Design hook text from opening script (3-5 key words)
-3. Group transcript words into caption groups (2-4 words)
-4. Choose consistent font family and style (bubble vs outlined)
-5. Select background music matching video mood
-6. Position captions to avoid covering subject
-7. Add zoom cuts at key moments
-8. Place emojis triggered by keywords
-9. Add transition effects between sections
-10. Add sound effects at key transitions
-11. Validate all timing syncs with actual audio
-
-## Common Mistakes
-
-- Captions not synced to actual audio
-- Multiple caption groups or text elements visible simultaneously
-- Text too small to read on mobile
-- Insufficient contrast between text and background
-- Captions covering subject's face
-- Emojis overlapping or too close to face
-- No size variation in hook text (all same size lacks impact)
-- Background music too loud relative to voice
-- Overusing effects and transitions
-- Inconsistent styling between hook and captions
diff --git a/.claude/skills/transitions/SKILL.md b/.claude/skills/transitions/SKILL.md
deleted file mode 100644
index cc99ba7f40..0000000000
--- a/.claude/skills/transitions/SKILL.md
+++ /dev/null
@@ -1,206 +0,0 @@
----
-name: transitions
-description: Scene transition constraints and patterns for HyperFrames. Use when adding crossfades, wipes, or reveals between scenes in a composition.
----
-
-# Transitions
-
-Constraints and patterns for scene-to-scene transitions in HyperFrames compositions.
-
-## When to Use
-
-- Adding a crossfade, wipe, or reveal between video clips or scenes
-- User asks for "smooth transition", "crossfade", or "scene change"
-- Connecting two sequential clips visually
-
-## Core Constraint: Same-Track Clips Cannot Overlap
-
-Clips on the same `data-track-index` cannot overlap in time. This is enforced by the framework. If two clips need to overlap (for a crossfade), they must be on **different tracks**.
-
-## Pattern: Crossfade
-
-Two clips on separate tracks. The outgoing clip fades out while the incoming clip fades in during the overlap window.
-
-```html
-
-
-
-
-
-```
-
-```js
-// Fade out clip A during overlap
-tl.to("#el-1", { opacity: 0, duration: 1, ease: "power2.inOut" }, 9);
-// Fade in clip B during overlap
-tl.fromTo("#el-2", { opacity: 0 }, { opacity: 1, duration: 1, ease: "power2.inOut" }, 9);
-```
-
-**Key rules:**
-
-- The overlap window is `incoming.start` to `outgoing.end`
-- Both clips must be on different tracks
-- Use `z-index` to control which clip renders on top during the transition
-- Animate `opacity` — do not animate `visibility` or `display`
-
-## Pattern: Wipe / Reveal
-
-An overlay composition that covers the frame, hides the cut, then reveals the new content.
-
-```html
-
-
-
-
-
-
-```
-
-Wipe composition (`compositions/wipe.html`):
-
-```html
-
-
-
-```
-
-**Key rules:**
-
-- The wipe composition spans the cut point (starts before the cut, ends after)
-- The wipe must have a high `z-index` to render above both clips
-- Clips can be on the same track (no overlap) since the wipe covers the hard cut
-- Time the wipe so the frame is fully covered at the exact cut point
-
-## Pattern: Scanline Sweep
-
-A partial-frame element sweeps across, creating a stylized transition.
-
-```js
-const tl = gsap.timeline({ paused: true });
-
-// Scanline starts above frame, sweeps down
-tl.fromTo("#scanline", { y: -320 }, { y: 1920, duration: 1.5, ease: "power3.inOut" }, 0);
-```
-
-Style the scanline with gradients, glow, or accent colors for visual interest.
-
-## Constraints
-
-- **Transitions are compositions.** Wrap transition elements in a composition with `data-composition-id`. Do not use bare divs without data attributes.
-- **Do not animate video element dimensions.** If you need to scale video during a transition, animate a wrapper div.
-- **Use GSAP, not CSS transitions.** CSS transitions are not deterministic in the renderer.
-- **Deterministic.** No `Math.random()`, no `Date.now()`.
-- **Transitions must not affect media playback.** Never call `play()`, `pause()`, or set `currentTime`. The framework owns media.
-- **Keep transitions short.** 0.3s–1.5s. Longer transitions feel sluggish in rendered video.
-
-## Timing with Relative References
-
-Use relative `data-start` to keep transitions aligned when clip durations change:
-
-```html
-
-
-
-
-
-
-```
-
-If `intro`'s duration changes, the wipe and `main` shift automatically.
-
-## Anti-Patterns
-
-- **Overlapping clips on the same track.** Framework will reject this. Use different tracks for crossfades.
-- **CSS `transition` property.** Not deterministic in headless rendering. Use GSAP.
-- **Animating `display` property.** Use `opacity` instead.
-- **Transitions longer than 2s.** Feels slow in video.
diff --git a/docs/superpowers/specs/2026-03-25-skills-and-transcription-design.md b/docs/superpowers/specs/2026-03-25-skills-and-transcription-design.md
deleted file mode 100644
index 69f4838353..0000000000
--- a/docs/superpowers/specs/2026-03-25-skills-and-transcription-design.md
+++ /dev/null
@@ -1,118 +0,0 @@
-# Skills & Transcription Design
-
-## Overview
-
-Three new HyperFrames skills (captions, transitions, media) and a CLI enhancement to add local transcription to `hyperframes init`.
-
----
-
-## Part 1: CLI — Transcription in `hyperframes init`
-
-### User Flow
-
-```
-npx hyperframes init
- → "Project name" → my-video
- → "Got a video?" → Yes → /path/to/video.mp4
- → (probe video: 1920x1080, 45.2s, has audio)
- → "Generate captions from audio?" → Yes
- → (extract audio → run whisper → save transcript)
- → "Pick a template" → warm-grain
- → (scaffold project with transcript.json + captions composition)
- → "Open in studio" / "Render" / "Done"
-```
-
-### Whisper Binary Management
-
-Follow the same pattern as the Chrome browser manager (`packages/cli/src/browser/manager.ts`):
-
-- **Cache location:** `~/.cache/hyperframes/whisper/`
-- **Binary:** whisper.cpp pre-built binary (download from GitHub releases)
-- **Model:** `ggml-base.en.bin` by default (~148MB), downloaded from Hugging Face on first use
-- **Resolution priority:** `--whisper-path` flag → `HYPERFRAMES_WHISPER_PATH` env var → cached binary → download
-
-Structure:
-```
-~/.cache/hyperframes/whisper/
-├── bin/
-│ └── whisper # whisper.cpp binary
-└── models/
- └── ggml-base.en.bin # default model
-```
-
-### Transcription Pipeline
-
-1. **Extract audio:** `ffmpeg -i video.mp4 -vn -ar 16000 -ac 1 -f wav /tmp/audio.wav`
-2. **Run whisper:** `whisper --model ~/.cache/hyperframes/whisper/models/ggml-base.en.bin --output-json --dtw base.en /tmp/audio.wav`
-3. **Parse output:** whisper.cpp JSON output → normalize to `transcript.json`
-4. **Save:** `transcript.json` in project root
-
-### transcript.json Format
-
-Use whisper.cpp's native JSON output format directly. The captions skill documents whatever format whisper.cpp outputs — no normalization layer.
-
-### Files to Change
-
-- `packages/cli/src/whisper/manager.ts` — new module: download, cache, resolve whisper binary + model (mirrors browser/manager.ts)
-- `packages/cli/src/whisper/transcribe.ts` — new module: extract audio, run whisper, return parsed result
-- `packages/cli/src/commands/init.ts` — add "Generate captions?" prompt after video probe, call transcribe, save transcript.json, pass to scaffolding
-- `packages/cli/src/commands/doctor.ts` — add whisper check
-
----
-
-## Part 2: Skills
-
-### Skill: `captions`
-
-**File:** `.claude/skills/captions/SKILL.md`
-
-**Purpose:** Given a whisper transcript file in the project, build or modify HyperFrames caption compositions.
-
-**Assumes:** `transcript.json` exists (output from `hyperframes init` or whisper.cpp directly).
-
-**Content covers:**
-- Whisper.cpp JSON structure reference (segments, tokens, timestamps)
-- Word grouping strategies: by sentence breaks, pause gaps (>150ms), max word count (3-5)
-- Composition structure: `` wrapper, `data-composition-id="captions"`, scoped CSS
-- GSAP patterns for caption entrance/exit (opacity, y offset, scale)
-- Positioning: bottom third centered, safe margins
-- Constraints: deterministic, no `Math.random()`, register in `window.__timelines`
-- Anti-patterns: don't animate `display`, don't overlap caption groups on same track
-
-### Skill: `transitions`
-
-**File:** `.claude/skills/transitions/SKILL.md`
-
-**Purpose:** Constraints and patterns for scene-to-scene transitions in HyperFrames.
-
-**Content covers:**
-- Track overlap rules: same track clips cannot overlap, use different tracks for crossfades
-- Crossfade pattern: two clips on separate tracks, opacity tween on outgoing clip
-- Wipe/reveal patterns: overlay div with GSAP-animated position
-- Timing: transition composition overlaps scene boundaries via `data-start` offset
-- Constraints: transitions are compositions (need `data-composition-id`), not bare divs
-- Anti-patterns: don't animate video element dimensions, don't use CSS transitions (use GSAP)
-
-### Skill: `media`
-
-**File:** `.claude/skills/media/SKILL.md`
-
-**Purpose:** Constraints for working with video, audio, and images in HyperFrames.
-
-**Content covers:**
-- Video: must be `muted` + `playsinline`, audio separate, `crossorigin="anonymous"` for CORS
-- Audio: separate `` element, `data-volume` for level control
-- Trimming: `data-media-start` for in-point offset
-- Codec requirements: H.264/VP8/VP9/AV1 for video, AAC/MP3/Opus for audio
-- Transparent video: WebM VP9 with alpha channel
-- Image: ` ` with `data-start`/`data-duration`/`data-track-index`
-- Framework owns playback: never call `play()`, `pause()`, set `currentTime`
-- PiP pattern: animate wrapper div, not the video element
-- Anti-patterns: don't nest video in timed div, don't animate video dimensions
-
----
-
-## Prioritization
-
-1. **Skills first** — captions, transitions, media (can ship immediately, no code changes)
-2. **CLI transcription** — whisper binary manager + init flow (requires engineering)
diff --git a/skills/captions/SKILL.md b/skills/captions/SKILL.md
new file mode 100644
index 0000000000..0831a51a2e
--- /dev/null
+++ b/skills/captions/SKILL.md
@@ -0,0 +1,138 @@
+---
+name: captions
+description: Build tone-adaptive captions from whisper transcripts. Detects script energy (hype, corporate, tutorial, storytelling, social) and applies matching typography, color, and animation. Supports per-word styling for brand names, ALL CAPS, numbers, and CTAs. Use when adding captions or subtitles to a HyperFrames composition.
+---
+
+# Captions
+
+Analyze the spoken content to determine caption style. If the user specifies a style, use that. Otherwise, detect tone from the transcript.
+
+## Transcript Source
+
+The project's `transcript.json` contains word-level timestamps from whisper.cpp (`--output-json-full` with `--dtw`):
+
+```json
+{
+ "transcription": [
+ {
+ "offsets": { "from": 0, "to": 5000 },
+ "text": " Hello world.",
+ "tokens": [
+ { "text": " Hello", "offsets": { "from": 0, "to": 1000 }, "p": 0.98 },
+ { "text": " world", "offsets": { "from": 1000, "to": 2000 }, "p": 0.95 }
+ ]
+ }
+ ]
+}
+```
+
+Normalize tokens into a word array before grouping:
+
+```js
+const words = [];
+for (const segment of transcript.transcription) {
+ for (const token of segment.tokens || []) {
+ const text = token.text.trim();
+ if (!text) continue;
+ words.push({
+ text,
+ start: token.offsets.from / 1000,
+ end: token.offsets.to / 1000,
+ });
+ }
+}
+```
+
+If no `transcript.json` exists, check for `.srt` or `.vtt` files. If no transcript is available, ask the user to provide one or run `hyperframes transcribe` (when available).
+
+## Style Detection (Default — When No Style Is Specified)
+
+Read the full transcript before choosing a style. The style comes from the content, not a template.
+
+### Four Dimensions
+
+**1. Visual feel** — the overall aesthetic personality:
+
+- Corporate/professional scripts → clean, minimal, restrained
+- Energetic/marketing scripts → bold, punchy, high-impact
+- Storytelling/narrative scripts → elegant, warm, cinematic
+- Technical/educational scripts → precise, high-contrast, structured
+- Social media/casual scripts → playful, dynamic, friendly
+
+**2. Color palette** — driven by the content's mood:
+
+- Dark backgrounds with bright accents for high energy
+- Muted/neutral tones for professional or calm content
+- High contrast (white on black, black on white) for clarity
+- One accent color for emphasis — not multiple
+
+**3. Font mood** — typography character, not specific font names:
+
+- Heavy/condensed for impact and energy
+- Clean sans-serif for modern and professional
+- Rounded for friendly and approachable
+- Serif for elegance and storytelling
+
+**4. Animation character** — how words enter and exit:
+
+- Scale-pop/slam for punchy energy
+- Gentle fade/slide for calm or professional
+- Word-by-word reveal for emphasis
+- Typewriter for technical or narrative pacing
+
+## Per-Word Styling
+
+Scan the script for words that deserve distinct visual treatment. Not every word is equal — some carry the message.
+
+### What to Detect
+
+- **Brand names / product names** — larger size, unique color, distinct entrance
+- **ALL CAPS words** — the author emphasized them intentionally. Scale boost, flash, or accent color.
+- **Numbers / statistics** — bold weight, accent color. Numbers are the payload in data-driven content.
+- **Emotional keywords** — "incredible", "insane", "amazing", "revolutionary" → exaggerated animation (overshoot, bounce)
+- **Proper nouns** — names of people, places, events → distinct accent or italic
+- **Call-to-action phrases** — "sign up", "get started", "try it now" → highlight, underline, or color pop
+
+### How to Apply
+
+For each detected word, specify:
+
+- Font size multiplier (e.g., 1.3x for emphasis, 1.5x for hero moments)
+- Color override (specific hex value)
+- Weight/style change (bolder, italic)
+- Animation variant (overshoot entrance, glow pulse, scale pop)
+
+## Script-to-Style Mapping
+
+| Script tone | Font mood | Animation | Color | Size |
+| -------------------- | ------------------------------------- | --------------------------------------- | -------------------------------------------- | -------------------- |
+| Hype/launch | Heavy condensed, 800-900 weight | Scale-pop, back.out(1.7), fast 0.1-0.2s | Bright accent on dark (cyan, yellow, lime) | Large 72-96px |
+| Corporate/pitch | Clean sans-serif, 600-700 weight | Fade + slide-up, power3.out, 0.3s | White/neutral on dark, single muted accent | Medium 56-72px |
+| Tutorial/educational | Mono or clean sans, 500-600 weight | Typewriter or gentle fade, 0.4-0.5s | High contrast, minimal color | Medium 48-64px |
+| Storytelling/brand | Serif or elegant sans, 400-500 weight | Slow fade, power2.out, 0.5-0.6s | Warm muted tones, low opacity (0.85-0.9) | Smaller 44-56px |
+| Social/casual | Rounded sans, 700-800 weight | Bounce, elastic.out, word-by-word | Playful colors, colored backgrounds on pills | Medium-large 56-80px |
+
+## Word Grouping by Tone
+
+Group size affects pacing. Fast content needs fast caption turnover.
+
+- **High energy:** 2-3 words per group. Quick turnover matches rapid delivery.
+- **Conversational:** 3-5 words per group. Natural phrase length.
+- **Measured/calm:** 4-6 words per group. Longer groups match slower pace.
+
+Break groups on sentence boundaries (`.` `?` `!`), pauses (>150ms gap), or max word count — whichever comes first.
+
+## Positioning
+
+- **Landscape (1920x1080):** Bottom 80-120px, centered
+- **Portrait (1080x1920):** Lower middle ~600-700px from bottom, centered
+- Never cover the subject's face
+- Use `position: absolute` — never relative (causes overflow)
+- One caption group visible at a time
+
+## Constraints
+
+- **Deterministic.** No `Math.random()`, no `Date.now()`.
+- **Sync to transcript timestamps.** Words appear when spoken.
+- **One group visible at a time.** No overlapping caption groups.
+- **Check project root** for font files before defaulting to Google Fonts.
diff --git a/skills/compose-video/SKILL.md b/skills/compose-video/SKILL.md
new file mode 100644
index 0000000000..e7dfd56776
--- /dev/null
+++ b/skills/compose-video/SKILL.md
@@ -0,0 +1,136 @@
+---
+name: compose-video
+description: Create HyperFrames HTML video compositions. Use when asked to create a video, build an animation, make a composition, add a title card, or generate any HTML-based video content for HyperFrames.
+---
+
+# Compose Video
+
+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.
+
+## 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-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
+
+Every composition is a `` wrapping a `` with `data-composition-id`. Each must include its own GSAP script and register its timeline:
+
+```html
+
+
+
+
+
+
+
+
+```
+
+Load in root: `
`
+
+## Video and Audio
+
+Video must be `muted playsinline`. Audio is always a separate `
` element:
+
+```html
+
+
+```
+
+## Timeline Contract
+
+- All timelines start `{ paused: true }` — the player controls playback
+- Register every timeline: `window.__timelines[""] = 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. The renderer must produce identical output every time.
+
+**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 — causes flickering in headless renders.
+
+**Never do:**
+
+1. Forget `window.__timelines` registration
+2. Use video for audio — always muted video + separate ``
+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`
+
+## Editing Existing Compositions
+
+- Read the full composition first — match existing fonts, colors, animation patterns
+- Only change what was requested — don't rewrite untouched sections
+- Don't rewrite entire files for small changes
+- Preserve timing of unrelated clips
+
+## Typography and Assets
+
+- Every composition loads its own fonts (`@import` or `@font-face` in `