diff --git a/.agents/skills/caption-animation/README.md b/.agents/skills/caption-animation/README.md
new file mode 100644
index 0000000..2685cf8
--- /dev/null
+++ b/.agents/skills/caption-animation/README.md
@@ -0,0 +1,24 @@
+# Caption Animation
+
+Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area. For short-form creators burning open captions onto vertical video.
+
+## When it activates
+
+- "Add animated captions," "make TikTok captions," or "karaoke captions."
+- "Word-by-word subtitles," "auto subtitles," or "highlight the active word."
+- "Burn in captions to a video" or "sync captions to a voiceover/narration."
+- "Turn an SRT/transcript into animated text."
+
+## Example prompts
+
+- "Turn this Whisper transcript into karaoke captions with a per-word pop."
+- "Restyle these auto subtitles into a branded, Hormozi-style reveal."
+- "Burn word-level captions onto my voiceover and export a sidecar SRT."
+
+## What's inside
+
+- `SKILL.md` — the transcribe → normalize → page → animate → place → export pipeline.
+- `references/word-timed-captions.md` — deeper guidance on word-level timing and animation.
+
+---
+Part of **[Short-Form Video Skills](../../)** · Built by **[iart.ai](https://iart.ai/?utm_source=github&utm_medium=readme&utm_campaign=tiktok-video-skills&utm_content=skill_footer&utm_term=caption-animation)** — the AI motion agent for editable, on-brand motion graphics.
diff --git a/.agents/skills/caption-animation/SKILL.md b/.agents/skills/caption-animation/SKILL.md
new file mode 100644
index 0000000..f648cbd
--- /dev/null
+++ b/.agents/skills/caption-animation/SKILL.md
@@ -0,0 +1,177 @@
+---
+name: caption-animation
+description: This skill should be used when the user asks to "add animated captions", "make TikTok captions", "karaoke captions", "word-by-word subtitles", "auto subtitles", "highlight the active word", "burn in captions to a video", "sync captions to a voiceover/narration", or "turn an SRT/transcript into animated text". Covers word-level timing from Whisper, per-word pop/scale-in, active-word highlight, 9:16 safe-area placement, readable type, and burn-in vs sidecar SRT/VTT.
+version: 0.1.0
+---
+
+# Caption Animation
+
+Turn a transcript or voiceover into word-timed, scroll-stopping captions: each word pops in on the syllable, the active word highlights, and the text stays glued to the narration. Build the kind of captions that drive watch-time on TikTok, Reels, and Shorts — readable, on-beat, and inside the safe area.
+
+## When to use
+
+- Word-by-word or karaoke captions on short-form vertical video (9:16).
+- Burning open captions onto a voiceover/narration track from a transcript.
+- Turning a Whisper/SRT transcript into animated text in Remotion or on the web.
+- Restyling auto-generated subtitles into a branded, "Hormozi-style" reveal.
+
+## The pipeline
+
+| Stage | Job | Tool |
+|---|---|---|
+| Transcribe | Audio → word-level timestamps | Whisper (`@remotion/install-whisper-cpp`), AssemblyAI |
+| Normalize | → `{ text, startMs, endMs }[]` tokens | `@remotion/captions` `Caption` type |
+| Page | Group words into 1–4 word "pages" | `createTikTokStyleCaptions()` |
+| Animate | Per-word pop + active-word highlight | Remotion `spring()` / CSS |
+| Place | Safe-area, readable type | layout rules below |
+| Export | Burn-in (MP4) and/or sidecar SRT/VTT | Remotion render / file emit |
+
+## Two non-negotiable rules
+
+1. **Word-level timing, not line-level.** Karaoke reads as magic only when each word lands on the syllable. Always transcribe to word timestamps; never fake them by splitting a line evenly over its duration — drift is instantly visible.
+2. **Readability beats style.** Captions are read on a phone, in sunlight, muted. Bold sans-serif, heavy stroke or shadow, high contrast first; decoration second. A caption nobody can read is decoration, not a caption.
+
+## Word timing from a transcript
+
+Whisper emits word-level timestamps directly. Normalize every source into one flat token shape so the renderer never cares where the words came from:
+
+```ts
+// Caption token — the one shape everything downstream consumes
+type Token = { text: string; startMs: number; endMs: number };
+
+// Parse an SRT cue block "00:00:01,200 --> 00:00:01,640" into ms
+const toMs = (t: string) => {
+ const [h, m, rest] = t.split(":");
+ const [s, ms] = rest.split(",");
+ return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
+};
+```
+
+If only sentence-level cues exist, re-transcribe for word timing — do not interpolate. Whisper's `medium.en` is ~2x faster than `large` and accurate enough for clean voice audio.
+
+## Per-word pop-in (Remotion)
+
+Drive each word's scale and opacity off a spring anchored to its own `startMs`. The micro-overshoot is what makes it feel "alive".
+
+```tsx
+import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
+
+const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const enter = (token.startMs / 1000) * fps; // word's own entrance frame
+ const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
+ const scale = interpolate(p, [0, 1], [0.6, 1]); // pop from 60% → 100% (overshoots)
+ return (
+ {token.text}
+ );
+};
+```
+
+Compute `active` by testing `frame` against each token's `[startMs, endMs]`. Keep the highlight a single accent color or a filled "box" behind the live word — never animate every word's color at once.
+
+## Pages, not walls of text
+
+Show 1–4 words at a time. Use Remotion's `createTikTokStyleCaptions()` (or group manually) and tune `combineTokensWithinMilliseconds`: ~200–500ms for true word-by-word, ~1000–1500ms for short readable phrases.
+
+```ts
+import { createTikTokStyleCaptions } from "@remotion/captions";
+const { pages } = createTikTokStyleCaptions({
+ captions, combineTokensWithinMilliseconds: 1200, // ~2–3 words per page
+});
+```
+
+## Web/CSS pop (no Remotion)
+
+For a DOM/live player, give each word its own `animation-delay` equal to `startMs`:
+
+```css
+.word { display:inline-block; opacity:0; animation: pop .26s cubic-bezier(.34,1.56,.64,1) forwards; }
+@keyframes pop { from { opacity:0; transform:translateY(.18em) scale(.7) } to { opacity:1; transform:none } }
+.word.active { color:#FFE45E; }
+```
+```js
+words.forEach(w => { const el = mk(w.text); el.style.animationDelay = `${w.startMs}ms`; track.append(el); });
+```
+
+## Readable type and safe-area placement (9:16, 1080×1920)
+
+| Property | Value | Why |
+|---|---|---|
+| Font | Bold/ExtraBold sans (Montserrat, Inter, Helvetica) | Reads on small, busy screens |
+| Size | 56–80px (≈8% of frame height), min 45px | Legible muted on a phone |
+| Stroke | 2–6px solid black outline | Survives any background |
+| Shadow | Soft drop shadow as backup to stroke | Separation on bright frames |
+| Case | Uppercase or sentence; high contrast fill | Punch + scannability |
+| Vertical pos | Center band, ~62–70% down | Above the UI, below the action |
+| Bottom safe | Keep clear of bottom ~280px / 15% | Avoids caption/CTA/audio UI |
+| Side safe | Keep within center 80% width | Avoids right-rail icons |
+
+Place captions in the center band — not pinned to the very bottom, where the platform UI (username, audio, buttons) lives. Pad text to a max of ~2 lines.
+
+## Sync to a voiceover / narration
+
+The transcript already carries the timing of the exact audio. Lay that same audio under the composition and keep timestamps in the audio's timebase — captions and voice stay locked with zero manual nudging. If the VO is re-recorded, re-transcribe; never hand-shift offsets.
+
+## Burn-in vs sidecar SRT/VTT
+
+| | Burn-in (open) | Sidecar SRT/VTT (closed) |
+|---|---|---|
+| Where | Pixels in the MP4 | Separate `.srt`/`.vtt` file |
+| Social (TikTok/Reels) | Required — guaranteed, styleable | Often ignored by the platform |
+| Accessibility (ADA/WCAG) | Does not satisfy alone | Required (toggleable) |
+| Best practice | Burn animated captions for social | Also ship a sidecar for the web/SEO |
+
+Emit both: burn the animated captions for social, and write a plain SRT/VTT from the same tokens for accessible/SEO playback.
+
+## Output checklist
+
+- Word-level timestamps (real, from transcription) — no even-split fakery.
+- Per-word pop with micro-overshoot; one accent color for the active word.
+- 1–4 words per page; never a wall of text.
+- Bold sans, 56–80px, 2–6px stroke + shadow, high contrast.
+- Center band, clear of bottom ~280px and side rails.
+- Audio laid under composition; timestamps in the audio timebase.
+- Ship burn-in for social and a sidecar SRT/VTT for accessibility.
+
+## Deliver & verify (rendered stills → MP4)
+
+> **Packaged helper** (`scripts/`): tile your stills with `scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png`, then assert the encode with `scripts/probe-mp4.sh out.mp4 [WxH] [fps]`. See `scripts/README.md`.
+
+Captions ship as a Remotion composition (`` + zod `schema` + `defaultProps`) — all word motion frame-driven off `useCurrentFrame()`, never `Date.now()` / `Math.random()` / timers. Deliverable = `out/*.mp4` (burned-in) + the project + the sidecar SRT/VTT. 9:16 vertical (1080×1920) is the default.
+
+**Verify loop — render stills → inspect → encode.** Word timing is the thing that breaks; check it at exact frames before you spend an encode.
+
+```bash
+# Stills at start / a sampled active-word frame / end — WITH SHIPPED PROPS (real tokens + audio)
+npx remotion still Captions out/f-start.png --frame=0 --props='{"captionsSrc":"vo.json"}'
+npx remotion still Captions out/f-mid.png --frame=90 --props='{"captionsSrc":"vo.json"}'
+npx remotion still Captions out/f-end.png --frame=N --props='{"captionsSrc":"vo.json"}' # N = durationInFrames-1
+
+# Inspect each PNG:
+# - the word highlighted at frame 90 is the word whose [startMs,endMs] contains 90/fps (no drift)
+# - burn-in legible: bold sans, stroke+shadow holds, no clipping
+# - 9:16 safe area: caption sits in the center band, clear of top ~12% and bottom ~20-35% (captions/CTA/audio UI) and the right action rail
+
+npx remotion render Captions out/captions.mp4 --props='{"captionsSrc":"vo.json"}' # encode once stills are right
+npx remotion render Captions out/demo.gif --codec=gif # README proof clip
+```
+
+Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the active-word + end frames.
+
+**Before you finish:**
+1. Stills render cleanly at frame 0, a mid active-word frame, and last — no missing font/audio.
+2. The correct word is highlighted at the sampled frame (frame/fps lands inside its token); no even-split fakery.
+3. Burn-in is legible (stroke+shadow) and the caption is fully inside the 9:16 safe area at every checked frame.
+4. Frame-driven only — no `Date.now()` / `Math.random()` / timers.
+5. Shipped props (real tokens, not just `defaultProps`) render correctly; MP4 + sidecar SRT/VTT emitted, GIF optional.
+
+## Reference files
+
+- `references/word-timed-captions.md` — end-to-end build: Whisper transcription and the `Caption` type, a full SRT parser, Remotion word-timed component with active-highlight, manual paging, an SRT/VTT emitter, per-platform safe-area maps, and a readable-type spec sheet.
diff --git a/.agents/skills/caption-animation/references/word-timed-captions.md b/.agents/skills/caption-animation/references/word-timed-captions.md
new file mode 100644
index 0000000..3bb9ceb
--- /dev/null
+++ b/.agents/skills/caption-animation/references/word-timed-captions.md
@@ -0,0 +1,254 @@
+# Word-Timed Captions — End-to-End Build
+
+A complete, runnable path from audio/transcript to animated burn-in captions plus a sidecar SRT/VTT. Code targets Remotion for burn-in and plain JS for the web; the data shapes are framework-agnostic.
+
+## 1. The one data shape
+
+Everything normalizes to a flat array of word tokens. Get to this shape and the renderer never cares about the source.
+
+```ts
+export type Token = { text: string; startMs: number; endMs: number };
+export type Page = { startMs: number; endMs: number; tokens: Token[] };
+```
+
+Remotion's `@remotion/captions` uses a compatible `Caption` type:
+
+```ts
+type Caption = { text: string; startMs: number; endMs: number; timestampMs: number | null; confidence: number | null };
+```
+
+## 2. Transcribe to word-level timing (Whisper)
+
+Word timestamps are non-negotiable for karaoke. Whisper produces them natively.
+
+```bash
+npx @remotion/install-whisper-cpp # one-time: installs whisper.cpp + a model
+```
+
+```ts
+import { transcribe, toCaptions } from "@remotion/install-whisper-cpp";
+
+const { transcription } = await transcribe({
+ inputPath: "voiceover.wav", // 16kHz mono WAV is ideal for whisper.cpp
+ model: "medium.en", // medium.en ≈ 2x faster than large, accurate on clean voice
+ tokenLevelTimestamps: true, // <-- the key flag: per-word, not per-line
+ whisperPath: "./whisper.cpp",
+});
+const { captions } = toCaptions({ transcription }); // → Caption[]
+```
+
+Tips:
+- Feed clean voice audio (denoise/normalize first). Background music wrecks word boundaries.
+- `large` is most accurate but 2–3x real-time on a laptop. Use `medium.en` unless accuracy is critical.
+- Cloud alternatives (AssemblyAI, Deepgram, OpenAI Whisper API) also return word timing — map their `words[]` into `Token` the same way.
+
+Map any token shape into `Token`:
+
+```ts
+const toTokens = (caps: Caption[]): Token[] =>
+ caps.map(c => ({ text: c.text.trim(), startMs: c.startMs, endMs: c.endMs }));
+```
+
+## 3. Parse an existing SRT into tokens
+
+When handed an SRT, parse cues; if cues are sentence-level, re-transcribe for word timing rather than interpolating.
+
+```ts
+const toMs = (t: string) => {
+ const [h, m, rest] = t.trim().split(":");
+ const [s, ms] = rest.split(",");
+ return ((+h * 60 + +m) * 60 + +s) * 1000 + +ms;
+};
+
+export function parseSrt(src: string): Token[] {
+ return src.trim().split(/\n\s*\n/).map(block => {
+ const lines = block.split("\n");
+ const time = lines.find(l => l.includes("-->"))!;
+ const [a, b] = time.split("-->");
+ const text = lines.slice(lines.indexOf(time) + 1).join(" ").trim();
+ return { text, startMs: toMs(a), endMs: toMs(b) };
+ });
+}
+```
+
+## 4. Page the tokens (1–4 words at a time)
+
+Either use Remotion's helper or group manually. Manual grouping by gap + max-words keeps full control:
+
+```ts
+export function paginate(tokens: Token[], maxWords = 3, maxGapMs = 1200): Page[] {
+ const pages: Page[] = [];
+ let cur: Token[] = [];
+ for (const t of tokens) {
+ const span = cur.length ? t.endMs - cur[0].startMs : 0;
+ if (cur.length >= maxWords || span > maxGapMs) {
+ if (cur.length) pages.push(pageOf(cur)); cur = [];
+ }
+ cur.push(t);
+ }
+ if (cur.length) pages.push(pageOf(cur));
+ return pages;
+}
+const pageOf = (ts: Token[]): Page => ({ startMs: ts[0].startMs, endMs: ts.at(-1)!.endMs, tokens: ts });
+```
+
+Remotion equivalent:
+
+```ts
+import { createTikTokStyleCaptions } from "@remotion/captions";
+const { pages } = createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: 1200 });
+// 200–500ms → word-by-word; 1000–1500ms → short readable phrases
+```
+
+## 5. Remotion component: word-timed with active highlight
+
+```tsx
+import { AbsoluteFill, Audio, Sequence, useCurrentFrame, useVideoConfig,
+ spring, interpolate, staticFile } from "remotion";
+
+const CAPTION_STYLE: React.CSSProperties = {
+ fontFamily: "Montserrat, Inter, sans-serif",
+ fontWeight: 800,
+ fontSize: 72, // ~8% of 1920px height
+ color: "#fff",
+ textAlign: "center",
+ textTransform: "uppercase",
+ WebkitTextStroke: "5px #000", // heavy outline survives any background
+ paintOrder: "stroke fill", // stroke behind fill, not clipping glyphs
+ textShadow: "0 4px 16px rgba(0,0,0,.55)",
+ lineHeight: 1.1,
+ padding: "0 80px", // keep inside center 80% width
+};
+
+const Word: React.FC<{ token: Token; active: boolean }> = ({ token, active }) => {
+ const frame = useCurrentFrame();
+ const { fps } = useVideoConfig();
+ const enter = (token.startMs / 1000) * fps;
+ const p = spring({ frame: frame - enter, fps, config: { damping: 12, mass: 0.6 } });
+ return (
+ {token.text}
+ );
+};
+
+export const Captions: React.FC<{ pages: Page[]; audio: string }> = ({ pages, audio }) => {
+ const { fps } = useVideoConfig();
+ const f = (ms: number) => Math.round((ms / 1000) * fps);
+ const frame = useCurrentFrame();
+ return (
+
+ {/* same audio the transcript came from */}
+ {pages.map((pg, i) => (
+
+
+