diff --git a/CLAUDE.md b/CLAUDE.md
index d77f482b5..d71f20cd3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ This repo ships skills that are installed globally via `npx hyperframes skills`
| Skill | Invoke with | When to use |
| ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **hyperframes-compose** | `/hyperframes-compose` | Creating ANY HTML composition — videos, animations, title cards, overlays. Contains required HTML structure, `class="clip"` rules, GSAP timeline patterns, and rendering constraints. |
-| **hyperframes-captions** | `/hyperframes-captions` | Building tone-adaptive captions from whisper transcripts — style detection, per-word styling, positioning. |
+| **hyperframes-captions** | `/hyperframes-captions` | Any task involving text synced to audio: captions, subtitles, lyrics, lyric videos, karaoke. Also covers transcription strategy (whisper model selection, transcript format). |
### GSAP Skills (from [greensock/gsap-skills](https://github.com/greensock/gsap-skills))
@@ -29,9 +29,12 @@ The skills encode HyperFrames-specific patterns (e.g., required `class="clip"` o
### Rules
- When creating or modifying HTML compositions → invoke `/hyperframes-compose` BEFORE writing any code
-- When adding captions → invoke `/hyperframes-captions` BEFORE writing any code
+- When adding captions, subtitles, lyrics, or any text synced to audio → invoke `/hyperframes-captions` BEFORE writing any code
+- When transcribing audio or choosing a whisper model → invoke `/hyperframes-captions` BEFORE running any transcription tool
+- When creating a video from audio (music video, lyric video, audio visualizer with text) → invoke BOTH `/hyperframes-compose` AND `/hyperframes-captions`
- When writing GSAP animations → invoke `/gsap-core` and `/gsap-timeline` BEFORE writing any code
- When optimizing animation performance → invoke `/gsap-performance` BEFORE making changes
+- After creating or editing any `.html` composition → run `npx hyperframes lint` and fix all errors before considering the task complete
### Installing skills
@@ -68,3 +71,48 @@ pnpm test # Run tests
- **Frame Adapters** bridge animation runtimes (GSAP, Lottie, CSS) to the capture engine
- **Producer** orchestrates capture → encode → audio mix into final MP4
- **BeginFrame rendering** uses `HeadlessExperimental.beginFrame` for deterministic frame capture
+
+## Transcription
+
+HyperFrames uses word-level timestamps for captions. The `hyperframes transcribe` command handles both transcription and format conversion.
+
+### Quick reference
+
+```bash
+# Transcribe audio/video (local whisper.cpp, no API key)
+npx hyperframes transcribe audio.mp3
+npx hyperframes transcribe video.mp4 --model medium.en --language en
+
+# Import existing transcript from another tool
+npx hyperframes transcribe subtitles.srt
+npx hyperframes transcribe subtitles.vtt
+npx hyperframes transcribe openai-response.json
+```
+
+### Whisper models
+
+Default is `small.en`. Upgrade for better accuracy:
+
+| Model | Size | Use case |
+| ----------- | ------ | -------------------------------- |
+| `tiny.en` | 75 MB | Quick testing |
+| `base.en` | 142 MB | Short clips, clear audio |
+| `small.en` | 466 MB | **Default** — most content |
+| `medium.en` | 1.5 GB | Important content, noisy audio |
+| `large-v3` | 3.1 GB | Multilingual, production quality |
+
+Use `.en` suffix for English-only (more accurate). Drop it for multilingual content.
+
+### Supported transcript formats
+
+The CLI auto-detects and normalizes: whisper.cpp JSON, OpenAI Whisper API JSON, SRT, VTT, and pre-normalized `[{text, start, end}]` arrays.
+
+### Improving transcription quality
+
+If captions are inaccurate (wrong words, bad timing):
+
+1. **Upgrade the model**: `--model medium.en` or `--model large-v3`
+2. **Set language**: `--language en` to filter non-target speech
+3. **Use an external API**: Transcribe via OpenAI or Groq Whisper API, then import the JSON with `hyperframes transcribe response.json`
+
+See the `/hyperframes-captions` skill for full details on model selection and API usage.
diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx
index 9ab8ac448..300b1542e 100644
--- a/docs/packages/cli.mdx
+++ b/docs/packages/cli.mdx
@@ -156,6 +156,8 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--audio, -a` | Path to an audio file (MP3, WAV, M4A) |
| `--skip-skills` | Skip AI coding skills installation |
| `--skip-transcribe` | Skip automatic whisper transcription |
+ | `--model` | Whisper model for transcription (e.g. `small.en`, `medium.en`, `large-v3`) |
+ | `--language` | Language code for transcription (e.g. `en`, `es`, `ja`). Filters non-target speech. |
| `--human-friendly` | Enable interactive terminal UI with prompts |
| Template | Description |
@@ -185,6 +187,45 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--json` | Output as JSON |
Shows each composition's ID, duration, resolution, and element count.
+
+ ### `transcribe`
+
+ Transcribe audio/video to word-level timestamps, or import an existing transcript:
+
+ ```bash
+ # Transcribe audio/video with local whisper.cpp
+ npx hyperframes transcribe audio.mp3
+ npx hyperframes transcribe video.mp4 --model medium.en --language en
+
+ # Import existing transcripts from other tools
+ npx hyperframes transcribe subtitles.srt
+ npx hyperframes transcribe captions.vtt
+ npx hyperframes transcribe openai-response.json
+ ```
+
+ | Flag | Description |
+ |------|-------------|
+ | `--dir, -d` | Project directory (default: current directory) |
+ | `--model, -m` | Whisper model (default: `small.en`). Options: `tiny.en`, `base.en`, `small.en`, `medium.en`, `large-v3` |
+ | `--language, -l` | Language code (e.g. `en`, `es`, `ja`). Filters out non-target language speech. |
+ | `--json` | Output result as JSON |
+
+ The command auto-detects the input type. Audio/video files are transcribed with whisper.cpp. Transcript files (`.json`, `.srt`, `.vtt`) are normalized and imported.
+
+ **Supported transcript formats:**
+
+ | Format | Source |
+ |--------|--------|
+ | whisper.cpp JSON | `hyperframes init --video`, `hyperframes transcribe` |
+ | OpenAI Whisper API JSON | `openai.audio.transcriptions.create()` with word timestamps |
+ | SRT subtitles | Video editors, YouTube, subtitle tools |
+ | VTT subtitles | Web players, YouTube, transcription services |
+
+ All formats are normalized to a standard `[{text, start, end}]` word array and saved as `transcript.json`. If the project has caption HTML files, they are automatically patched with the transcript data.
+
+
+ For music or noisy audio, use `--model medium.en` for better accuracy. For the best results with production content, transcribe via the OpenAI or Groq Whisper API and import the JSON.
+
### `dev`
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 952303292..e994de17d 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -26,6 +26,7 @@ const subCommands = {
benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default),
skills: () => import("./commands/install-skills.js").then((m) => m.default),
+ transcribe: () => import("./commands/transcribe.js").then((m) => m.default),
docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts
index 4c43bcfcb..38a01bfeb 100644
--- a/packages/cli/src/commands/init.ts
+++ b/packages/cli/src/commands/init.ts
@@ -246,60 +246,11 @@ function patchVideoSrc(
}
}
-function patchTranscript(dir: string, transcriptPath: string): void {
- // Read the whisper transcript and normalize to [{text, start, end}]
- const raw = JSON.parse(readFileSync(transcriptPath, "utf-8"));
- const words: { text: string; start: number; end: number }[] = [];
- for (const seg of raw.transcription ?? []) {
- for (const token of seg.tokens ?? []) {
- const text = (token.text ?? "").trim();
- if (!text || text.startsWith("[_") || text.startsWith("[BLANK")) continue;
-
- // Merge punctuation with the previous word
- const isPunctuation = /^[.,!?;:'")\]}>…–—-]+$/.test(text);
- const lastWord = words[words.length - 1];
- if (isPunctuation && lastWord) {
- lastWord.text += text;
- lastWord.end = Math.round(((token.offsets?.to ?? 0) / 1000) * 1000) / 1000;
- continue;
- }
-
- words.push({
- text,
- start: Math.round(((token.offsets?.from ?? 0) / 1000) * 1000) / 1000,
- end: Math.round(((token.offsets?.to ?? 0) / 1000) * 1000) / 1000,
- });
- }
- }
-
+async function patchTranscript(dir: string, transcriptPath: string): Promise {
+ const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
+ const { words } = loadTranscript(transcriptPath);
if (words.length === 0) return;
-
- const wordsJson = JSON.stringify(words, null, 10)
- .replace(/^\[/, "[")
- .replace(/\n {10}/g, "\n ");
-
- // Find captions HTML files and replace the hardcoded script array
- const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
- .filter((e) => e.isFile() && e.name.endsWith(".html"))
- .map((e) => join(e.parentPath ?? e.path, e.name));
-
- for (const file of htmlFiles) {
- let content = readFileSync(file, "utf-8");
- // Match within