feat(cli,core): standalone transcribe command, transcript normalization, caption lint rules (#151)

* feat(cli,core): add standalone transcribe command, transcript normalization, and caption lint rules

- Add `hyperframes transcribe` command for transcribing audio/video and importing
  existing transcripts (SRT, VTT, OpenAI Whisper API JSON, whisper.cpp JSON)
- Add transcript format normalizer (normalize.ts) with auto-detection and
  conversion to standard [{text, start, end}] word arrays
- Upgrade default whisper model from base.en to small.en for better accuracy
- Add --model and --language flags to both `transcribe` and `init` commands
- Extract shared patchCaptionHtml() to eliminate duplication between init.ts
  and transcribe.ts (init.ts reduced by ~55 lines)
- Add 3 caption lint rules: caption_exit_missing_hard_kill,
  caption_text_overflow_risk, caption_container_relative_position
- Update captions skill with model guide, format docs, music guidance,
  text overflow prevention, caption exit guarantee pattern
- Expand captions skill trigger to cover lyrics, karaoke, lyric videos

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(cli): add transcribe command and --model/--language flags to CLI docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): fix blank template lint issues

- blank/index.html: remove data-start from video (was nested in timed parent),
  add class="clip" for initial hidden state
- blank/captions.html: add max-width + overflow:hidden to prevent text clipping,
  add tl.set hard kill after exit tween to prevent stuck captions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add lint-after-edit rule to repo and project CLAUDE.md

Agents must run `npx hyperframes lint` after editing compositions.
Also expand captions skill description in project template.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format _shared/CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-03-30 18:58:06 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 7b18c0352e
commit 2f99e33bbe
15 changed files with 1176 additions and 105 deletions
+50 -2
View File
@@ -9,7 +9,7 @@ This repo ships skills that are installed globally via `npx hyperframes skills`
| Skill | Invoke with | When to use | | 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-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)) ### 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 ### Rules
- When creating or modifying HTML compositions → invoke `/hyperframes-compose` BEFORE writing any code - 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 writing GSAP animations → invoke `/gsap-core` and `/gsap-timeline` BEFORE writing any code
- When optimizing animation performance → invoke `/gsap-performance` BEFORE making changes - 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 ### Installing skills
@@ -68,3 +71,48 @@ pnpm test # Run tests
- **Frame Adapters** bridge animation runtimes (GSAP, Lottie, CSS) to the capture engine - **Frame Adapters** bridge animation runtimes (GSAP, Lottie, CSS) to the capture engine
- **Producer** orchestrates capture → encode → audio mix into final MP4 - **Producer** orchestrates capture → encode → audio mix into final MP4
- **BeginFrame rendering** uses `HeadlessExperimental.beginFrame` for deterministic frame capture - **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.
+41
View File
@@ -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) | | `--audio, -a` | Path to an audio file (MP3, WAV, M4A) |
| `--skip-skills` | Skip AI coding skills installation | | `--skip-skills` | Skip AI coding skills installation |
| `--skip-transcribe` | Skip automatic whisper transcription | | `--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 | | `--human-friendly` | Enable interactive terminal UI with prompts |
| Template | Description | | Template | Description |
@@ -185,6 +187,45 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--json` | Output as JSON | | `--json` | Output as JSON |
Shows each composition's ID, duration, resolution, and element count. 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.
<Tip>
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.
</Tip>
</Tab> </Tab>
<Tab title="Develop"> <Tab title="Develop">
### `dev` ### `dev`
+1
View File
@@ -26,6 +26,7 @@ const subCommands = {
benchmark: () => import("./commands/benchmark.js").then((m) => m.default), benchmark: () => import("./commands/benchmark.js").then((m) => m.default),
browser: () => import("./commands/browser.js").then((m) => m.default), browser: () => import("./commands/browser.js").then((m) => m.default),
skills: () => import("./commands/install-skills.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), docs: () => import("./commands/docs.js").then((m) => m.default),
doctor: () => import("./commands/doctor.js").then((m) => m.default), doctor: () => import("./commands/doctor.js").then((m) => m.default),
upgrade: () => import("./commands/upgrade.js").then((m) => m.default), upgrade: () => import("./commands/upgrade.js").then((m) => m.default),
+25 -57
View File
@@ -246,60 +246,11 @@ function patchVideoSrc(
} }
} }
function patchTranscript(dir: string, transcriptPath: string): void { async function patchTranscript(dir: string, transcriptPath: string): Promise<void> {
// Read the whisper transcript and normalize to [{text, start, end}] const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const raw = JSON.parse(readFileSync(transcriptPath, "utf-8")); const { words } = loadTranscript(transcriptPath);
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,
});
}
}
if (words.length === 0) return; if (words.length === 0) return;
patchCaptionHtml(dir, words);
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 <script> blocks only to avoid crossing block boundaries
const scriptBlocks = content.match(/<script>[\s\S]*?<\/script>/g) ?? [];
let scriptMatch: RegExpMatchArray | null = null;
let transcriptMatch: RegExpMatchArray | null = null;
for (const block of scriptBlocks) {
scriptMatch = scriptMatch ?? block.match(/const script = \[[\s\S]*?\];/);
transcriptMatch = transcriptMatch ?? block.match(/const TRANSCRIPT = \[[\s\S]*?\];/);
}
const match = scriptMatch ?? transcriptMatch;
if (match) {
const varName = scriptMatch ? "script" : "TRANSCRIPT";
content = content.replace(match[0], `const ${varName} = ${wordsJson};`);
writeFileSync(file, content, "utf-8");
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -543,6 +494,16 @@ Examples:
type: "boolean", type: "boolean",
description: "Skip whisper transcription", description: "Skip whisper transcription",
}, },
model: {
type: "string",
description:
"Whisper model for transcription (e.g. tiny.en, base.en, small.en, medium.en, large)",
},
language: {
type: "string",
description:
"Language code for transcription (e.g. en, es, ja). Filters out non-target speech.",
},
"non-interactive": { "non-interactive": {
type: "boolean", type: "boolean",
description: "Disable interactive prompts (for CI/agents)", description: "Disable interactive prompts (for CI/agents)",
@@ -555,6 +516,8 @@ Examples:
const skipSkills = args["skip-skills"] === true; const skipSkills = args["skip-skills"] === true;
const skipTranscribe = args["skip-transcribe"] === true; const skipTranscribe = args["skip-transcribe"] === true;
const nonInteractive = args["non-interactive"] === true; const nonInteractive = args["non-interactive"] === true;
const modelFlag = args.model;
const languageFlag = args.language;
const interactive = !nonInteractive && process.stdout.isTTY === true; const interactive = !nonInteractive && process.stdout.isTTY === true;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -615,10 +578,13 @@ Examples:
try { try {
const { ensureWhisper, ensureModel } = await import("../whisper/manager.js"); const { ensureWhisper, ensureModel } = await import("../whisper/manager.js");
await ensureWhisper(); await ensureWhisper();
await ensureModel(); await ensureModel(modelFlag);
console.log("Transcribing..."); console.log("Transcribing...");
const { transcribe: runTranscribe } = await import("../whisper/transcribe.js"); const { transcribe: runTranscribe } = await import("../whisper/transcribe.js");
const result = await runTranscribe(sourceFilePath, destDir); const result = await runTranscribe(sourceFilePath, destDir, {
model: modelFlag,
language: languageFlag,
});
console.log( console.log(
`Transcribed: ${result.wordCount} words (${result.durationSeconds.toFixed(1)}s)`, `Transcribed: ${result.wordCount} words (${result.durationSeconds.toFixed(1)}s)`,
); );
@@ -633,7 +599,7 @@ Examples:
trackInitTemplate(templateId); trackInitTemplate(templateId);
const transcriptFile = resolve(destDir, "transcript.json"); const transcriptFile = resolve(destDir, "transcript.json");
if (existsSync(transcriptFile)) { if (existsSync(transcriptFile)) {
patchTranscript(destDir, transcriptFile); await patchTranscript(destDir, transcriptFile);
} }
// Skills // Skills
@@ -796,13 +762,15 @@ Examples:
await ensureWhisper({ await ensureWhisper({
onProgress: (msg) => spin.message(msg), onProgress: (msg) => spin.message(msg),
}); });
await ensureModel(undefined, { await ensureModel(modelFlag, {
onProgress: (msg) => spin.message(msg), onProgress: (msg) => spin.message(msg),
}); });
spin.message("Transcribing audio..."); spin.message("Transcribing audio...");
const { transcribe: runTranscribe } = await import("../whisper/transcribe.js"); const { transcribe: runTranscribe } = await import("../whisper/transcribe.js");
const transcribeResult = await runTranscribe(sourceFilePath, destDir, { const transcribeResult = await runTranscribe(sourceFilePath, destDir, {
model: modelFlag,
language: languageFlag,
onProgress: (msg) => spin.message(msg), onProgress: (msg) => spin.message(msg),
}); });
spin.stop( spin.stop(
+149
View File
@@ -0,0 +1,149 @@
import { defineCommand } from "citty";
import { existsSync, writeFileSync } from "node:fs";
import { resolve, join, extname } from "node:path";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { DEFAULT_MODEL } from "../whisper/manager.js";
export default defineCommand({
meta: {
name: "transcribe",
description:
"Transcribe audio/video to word-level timestamps, or import an existing transcript",
},
args: {
input: {
type: "positional",
description:
"Audio/video file to transcribe, or transcript file to import (.json, .srt, .vtt)",
required: true,
},
dir: {
type: "string",
description: "Project directory (default: current directory)",
alias: "d",
},
model: {
type: "string",
description: `Whisper model (default: ${DEFAULT_MODEL}). Options: tiny.en, base.en, small.en, medium.en, large-v3`,
alias: "m",
},
language: {
type: "string",
description: "Language code (e.g. en, es, ja). Filters out non-target language speech.",
alias: "l",
},
json: {
type: "boolean",
description: "Output result as JSON",
default: false,
},
},
async run({ args }) {
const inputPath = resolve(args.input);
if (!existsSync(inputPath)) {
console.error(c.error(`File not found: ${args.input}`));
process.exit(1);
}
const dir = resolve(args.dir ?? ".");
const ext = extname(inputPath).toLowerCase();
// ── Import mode: convert existing transcript ──────────────────────────
const isImport = ext === ".json" || ext === ".srt" || ext === ".vtt";
if (isImport) {
return importTranscript(inputPath, dir, args.json);
}
// ── Transcribe mode: run whisper ─────────────────────────────────────
return transcribeAudio(inputPath, dir, {
model: args.model,
language: args.language,
json: args.json,
});
},
});
// ---------------------------------------------------------------------------
// Import existing transcript
// ---------------------------------------------------------------------------
async function importTranscript(inputPath: string, dir: string, json: boolean): Promise<void> {
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const { words, format } = loadTranscript(inputPath);
if (words.length === 0) {
console.error(c.error("No words found in transcript."));
process.exit(1);
}
const outPath = join(dir, "transcript.json");
writeFileSync(outPath, JSON.stringify(words, null, 2));
patchCaptionHtml(dir, words);
if (json) {
console.log(
JSON.stringify({ ok: true, format, wordCount: words.length, transcriptPath: outPath }),
);
} else {
console.log(
`${c.success("◇")} Imported ${c.accent(String(words.length))} words from ${c.accent(format)} format → ${c.accent("transcript.json")}`,
);
}
}
// ---------------------------------------------------------------------------
// Transcribe audio/video with whisper
// ---------------------------------------------------------------------------
async function transcribeAudio(
inputPath: string,
dir: string,
opts: { model?: string; language?: string; json?: boolean },
): Promise<void> {
const { transcribe } = await import("../whisper/transcribe.js");
const { loadTranscript, patchCaptionHtml } = await import("../whisper/normalize.js");
const model = opts.model ?? DEFAULT_MODEL;
const spin = opts.json ? null : clack.spinner();
spin?.start(`Transcribing with ${c.accent(model)}...`);
try {
const result = await transcribe(inputPath, dir, {
model,
language: opts.language,
onProgress: spin ? (msg) => spin.message(msg) : undefined,
});
const { words } = loadTranscript(result.transcriptPath);
writeFileSync(result.transcriptPath, JSON.stringify(words, null, 2));
patchCaptionHtml(dir, words);
if (opts.json) {
console.log(
JSON.stringify({
ok: true,
model,
wordCount: words.length,
durationSeconds: result.durationSeconds,
transcriptPath: result.transcriptPath,
}),
);
} else {
spin!.stop(
c.success(
`Transcribed ${c.accent(String(words.length))} words (${result.durationSeconds.toFixed(1)}s)`,
),
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (opts.json) {
console.log(JSON.stringify({ ok: false, error: message }));
} else {
spin!.stop(c.error(`Transcription failed: ${message}`));
}
process.exit(1);
}
}
+11 -1
View File
@@ -7,7 +7,7 @@
| Skill | Command | When to use | | Skill | Command | When to use |
| ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------ | | ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------ |
| **hyperframes-compose** | `/hyperframes-compose` | Creating or editing ANY HTML composition — videos, animations, title cards, overlays, sub-compositions | | **hyperframes-compose** | `/hyperframes-compose` | Creating or editing ANY HTML composition — videos, animations, title cards, overlays, sub-compositions |
| **hyperframes-captions** | `/hyperframes-captions` | Building captions from whisper transcripts — style detection, per-word styling | | **hyperframes-captions** | `/hyperframes-captions` | Any text synced to audio: captions, subtitles, lyrics, karaoke. Also covers transcription strategy. |
| **gsap-core** | `/gsap-core` | GSAP tweens: `gsap.to()`, `from()`, `fromTo()`, easing, stagger, defaults | | **gsap-core** | `/gsap-core` | GSAP tweens: `gsap.to()`, `from()`, `fromTo()`, easing, stagger, defaults |
| **gsap-timeline** | `/gsap-timeline` | Timeline sequencing, position parameter, labels, nesting | | **gsap-timeline** | `/gsap-timeline` | Timeline sequencing, position parameter, labels, nesting |
| **gsap-performance** | `/gsap-performance` | Animation performance — transforms over layout props, will-change, batching | | **gsap-performance** | `/gsap-performance` | Animation performance — transforms over layout props, will-change, batching |
@@ -50,6 +50,16 @@ https://hyperframes.heygen.com/llms.txt
- `meta.json` — project metadata (id, name) - `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated) - `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the linter before considering the task complete:
```bash
npx hyperframes lint
```
Fix all errors before presenting the result. Warnings are informational and usually safe to ignore.
## Key Rules ## Key Rules
1. Every timed element needs `data-start`, `data-duration`, and `data-track-index` 1. Every timed element needs `data-start`, `data-duration`, and `data-track-index`
@@ -37,6 +37,8 @@
0 2px 8px rgba(0, 0, 0, 0.8), 0 2px 8px rgba(0, 0, 0, 0.8),
0 0 2px rgba(0, 0, 0, 0.9); 0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap; white-space: nowrap;
max-width: 1600px;
overflow: hidden;
} }
</style> </style>
@@ -83,6 +85,7 @@
index < lines.length - 1 ? Math.min(line.end, lines[index + 1].start) : line.end; index < lines.length - 1 ? Math.min(line.end, lines[index + 1].start) : line.end;
tl.to(el, { opacity: 0, y: -10, duration: 0.25, ease: "power2.in" }, hideTime - 0.25); tl.to(el, { opacity: 0, y: -10, duration: 0.25, ease: "power2.in" }, hideTime - 0.25);
tl.set(el, { opacity: 0, visibility: "hidden" }, hideTime);
}); });
window.__timelines["captions"] = tl; window.__timelines["captions"] = tl;
+1 -1
View File
@@ -31,10 +31,10 @@
> >
<video <video
id="a-roll" id="a-roll"
class="clip"
src="__VIDEO_SRC__" src="__VIDEO_SRC__"
muted muted
playsinline playsinline
data-start="0"
data-duration="__VIDEO_DURATION__" data-duration="__VIDEO_DURATION__"
data-track-index="0" data-track-index="0"
style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover" style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover"
+1 -1
View File
@@ -6,7 +6,7 @@ import { get as httpsGet } from "node:https";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models"); const MODELS_DIR = join(homedir(), ".cache", "hyperframes", "whisper", "models");
const DEFAULT_MODEL = "base.en"; const DEFAULT_MODEL = "small.en";
export type WhisperSource = "env" | "system" | "brew" | "build"; export type WhisperSource = "env" | "system" | "brew" | "build";
+278
View File
@@ -0,0 +1,278 @@
import { describe, it, expect, afterEach } from "vitest";
import { writeFileSync, readFileSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { loadTranscript, detectFormat, patchCaptionHtml } from "./normalize.js";
function tmpFile(name: string, content: string): string {
const dir = join(tmpdir(), `hf-normalize-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
dirs.push(dir);
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
let dirs: string[] = [];
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs = [];
});
describe("detectFormat", () => {
it("detects SRT by extension", () => {
const path = tmpFile("test.srt", "1\n00:00:01,000 --> 00:00:02,000\nHello\n");
expect(detectFormat(path)).toBe("srt");
});
it("detects VTT by extension", () => {
const path = tmpFile("test.vtt", "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHello\n");
expect(detectFormat(path)).toBe("vtt");
});
it("detects whisper-cpp JSON", () => {
const path = tmpFile(
"transcript.json",
JSON.stringify({
transcription: [
{
offsets: { from: 0, to: 2000 },
text: " Hello world.",
tokens: [
{ text: " Hello", offsets: { from: 0, to: 1000 }, p: 0.98 },
{ text: " world", offsets: { from: 1000, to: 2000 }, p: 0.95 },
],
},
],
}),
);
expect(detectFormat(path)).toBe("whisper-cpp");
});
it("detects OpenAI JSON", () => {
const path = tmpFile(
"openai.json",
JSON.stringify({
words: [
{ word: "Hello", start: 0.0, end: 0.5 },
{ word: "world", start: 0.6, end: 1.2 },
],
}),
);
expect(detectFormat(path)).toBe("openai");
});
it("detects normalized word array", () => {
const path = tmpFile(
"words.json",
JSON.stringify([
{ text: "Hello", start: 0.0, end: 0.5 },
{ text: "world", start: 0.6, end: 1.2 },
]),
);
expect(detectFormat(path)).toBe("words-json");
});
});
describe("loadTranscript", () => {
it("parses whisper-cpp JSON with punctuation merging", () => {
const path = tmpFile(
"transcript.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: " Hello", offsets: { from: 0, to: 500 } },
{ text: ",", offsets: { from: 500, to: 550 } },
{ text: " world", offsets: { from: 600, to: 1200 } },
{ text: ".", offsets: { from: 1200, to: 1250 } },
],
},
],
}),
);
const { words, format } = loadTranscript(path);
expect(format).toBe("whisper-cpp");
expect(words).toEqual([
{ text: "Hello,", start: 0, end: 0.55 },
{ text: "world.", start: 0.6, end: 1.25 },
]);
});
it("filters whisper-cpp non-speech tokens", () => {
const path = tmpFile(
"transcript.json",
JSON.stringify({
transcription: [
{
tokens: [
{ text: "[_BEG_]", offsets: { from: 0, to: 0 } },
{ text: " Hello", offsets: { from: 100, to: 500 } },
{ text: "[BLANK_AUDIO]", offsets: { from: 500, to: 1000 } },
],
},
],
}),
);
const { words } = loadTranscript(path);
expect(words).toHaveLength(1);
expect(words[0]!.text).toBe("Hello");
});
it("parses OpenAI Whisper API response", () => {
const path = tmpFile(
"openai.json",
JSON.stringify({
text: "Hello world",
words: [
{ word: "Hello", start: 0.0, end: 0.5 },
{ word: "world", start: 0.6, end: 1.2 },
],
}),
);
const { words, format } = loadTranscript(path);
expect(format).toBe("openai");
expect(words).toEqual([
{ text: "Hello", start: 0, end: 0.5 },
{ text: "world", start: 0.6, end: 1.2 },
]);
});
it("parses SRT files", () => {
const srt = `1
00:00:01,000 --> 00:00:03,500
Hello world
2
00:00:04,000 --> 00:00:06,000
How are you
`;
const path = tmpFile("captions.srt", srt);
const { words, format } = loadTranscript(path);
expect(format).toBe("srt");
expect(words).toEqual([
{ text: "Hello world", start: 1.0, end: 3.5 },
{ text: "How are you", start: 4.0, end: 6.0 },
]);
});
it("parses VTT files", () => {
const vtt = `WEBVTT
00:00:01.000 --> 00:00:03.500
Hello world
00:00:04.000 --> 00:00:06.000
How are you
`;
const path = tmpFile("captions.vtt", vtt);
const { words, format } = loadTranscript(path);
expect(format).toBe("vtt");
expect(words).toEqual([
{ text: "Hello world", start: 1.0, end: 3.5 },
{ text: "How are you", start: 4.0, end: 6.0 },
]);
});
it("parses VTT with short timestamps (MM:SS.mmm)", () => {
const vtt = `WEBVTT
01:23.456 --> 02:00.000
Short format
`;
const path = tmpFile("short.vtt", vtt);
const { words } = loadTranscript(path);
expect(words[0]!.start).toBeCloseTo(83.456, 2);
expect(words[0]!.end).toBe(120.0);
});
it("strips HTML tags from SRT/VTT", () => {
const srt = `1
00:00:01,000 --> 00:00:03,000
<b>Bold</b> and <i>italic</i>
`;
const path = tmpFile("tags.srt", srt);
const { words } = loadTranscript(path);
expect(words[0]!.text).toBe("Bold and italic");
});
it("passes through normalized word arrays", () => {
const input = [
{ text: "Hello", start: 0.0, end: 0.5 },
{ text: "world", start: 0.6, end: 1.2 },
];
const path = tmpFile("normalized.json", JSON.stringify(input));
const { words, format } = loadTranscript(path);
expect(format).toBe("words-json");
expect(words).toEqual(input);
});
});
describe("patchCaptionHtml", () => {
it("replaces const script = [] in HTML files", () => {
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
dirs.push(dir);
const html = `<html><body><script>
const script = [];
console.log(script);
</script></body></html>`;
writeFileSync(join(dir, "captions.html"), html);
const words = [
{ text: "Hello", start: 1.0, end: 1.5 },
{ text: "world", start: 2.0, end: 2.5 },
];
patchCaptionHtml(dir, words);
const result = readFileSync(join(dir, "captions.html"), "utf-8");
expect(result).toContain('"Hello"');
expect(result).toContain('"world"');
expect(result).not.toContain("const script = [];");
});
it("replaces const TRANSCRIPT = [] variant", () => {
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
dirs.push(dir);
const html = `<script>const TRANSCRIPT = [];</script>`;
writeFileSync(join(dir, "index.html"), html);
patchCaptionHtml(dir, [{ text: "Hi", start: 0, end: 1 }]);
const result = readFileSync(join(dir, "index.html"), "utf-8");
expect(result).toContain("const TRANSCRIPT = ");
expect(result).toContain('"Hi"');
});
it("does not modify HTML files without matching script patterns", () => {
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
dirs.push(dir);
const html = `<html><body><script>console.log("hello");</script></body></html>`;
writeFileSync(join(dir, "page.html"), html);
patchCaptionHtml(dir, [{ text: "Hi", start: 0, end: 1 }]);
const result = readFileSync(join(dir, "page.html"), "utf-8");
expect(result).toBe(html);
});
it("skips empty word arrays", () => {
const dir = join(tmpdir(), `hf-patch-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
dirs.push(dir);
const html = `<script>const script = [];</script>`;
writeFileSync(join(dir, "captions.html"), html);
patchCaptionHtml(dir, []);
const result = readFileSync(join(dir, "captions.html"), "utf-8");
expect(result).toBe(html);
});
});
+266
View File
@@ -0,0 +1,266 @@
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { extname, join } from "node:path";
export interface Word {
text: string;
start: number;
end: number;
}
// ---------------------------------------------------------------------------
// Format detection + parsing
// ---------------------------------------------------------------------------
export type TranscriptFormat = "whisper-cpp" | "openai" | "srt" | "vtt" | "words-json";
/**
* Detect the format of a transcript file from its extension and content.
*/
export function detectFormat(filePath: string): TranscriptFormat {
const ext = extname(filePath).toLowerCase();
if (ext === ".srt") return "srt";
if (ext === ".vtt") return "vtt";
if (ext === ".json") return detectJsonFormat(JSON.parse(readFileSync(filePath, "utf-8")));
throw new Error(`Unsupported transcript file extension: ${ext}. Use .json, .srt, or .vtt`);
}
function detectJsonFormat(raw: unknown): TranscriptFormat {
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>;
if (obj.transcription && Array.isArray(obj.transcription)) return "whisper-cpp";
if (obj.words && Array.isArray(obj.words)) return "openai";
}
if (Array.isArray(raw) && raw[0]?.text !== undefined && raw[0]?.start !== undefined) {
return "words-json";
}
throw new Error(
"Unrecognized JSON transcript format. Expected whisper.cpp (transcription[].tokens), " +
"OpenAI API (words[]), or normalized ([{text, start, end}]).",
);
}
// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------
function parseWhisperCpp(data: Record<string, unknown>): Word[] {
const words: Word[] = [];
const transcription = data.transcription as Array<{
tokens?: Array<{
text?: string;
offsets?: { from?: number; to?: number };
}>;
}>;
for (const seg of 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 = round3((token.offsets?.to ?? 0) / 1000);
continue;
}
words.push({
text,
start: round3((token.offsets?.from ?? 0) / 1000),
end: round3((token.offsets?.to ?? 0) / 1000),
});
}
}
return words;
}
function parseOpenAI(data: Record<string, unknown>): Word[] {
const rawWords = (data.words ?? []) as Array<{
word?: string;
text?: string;
start?: number;
end?: number;
}>;
return rawWords
.map((w) => ({
text: (w.word ?? w.text ?? "").trim(),
start: round3(w.start ?? 0),
end: round3(w.end ?? 0),
}))
.filter((w) => w.text.length > 0);
}
function parseSrt(content: string): Word[] {
// SRT doesn't have word-level timestamps — parse as phrase-level entries.
// Each cue becomes one "word" entry (the full phrase).
const blocks = content.trim().split(/\n\n+/);
const words: Word[] = [];
for (const block of blocks) {
const lines = block.trim().split("\n");
// SRT format: index, timestamp line, text lines
const timeLine = lines.find((l) => l.includes("-->"));
if (!timeLine) continue;
const [startStr, endStr] = timeLine.split("-->").map((s) => s.trim());
if (!startStr || !endStr) continue;
const text = lines
.slice(lines.indexOf(timeLine) + 1)
.join(" ")
.replace(/<[^>]+>/g, "") // strip HTML tags
.trim();
if (!text) continue;
words.push({
text,
start: parseSrtTimestamp(startStr),
end: parseSrtTimestamp(endStr),
});
}
return words;
}
function parseVtt(content: string): Word[] {
// Strip the WEBVTT header and any metadata blocks
const body = content.replace(/^WEBVTT[^\n]*\n/, "").replace(/^[A-Z-]+:.*\n/gm, "");
// VTT is structurally similar to SRT (without numeric indices)
const blocks = body.trim().split(/\n\n+/);
const words: Word[] = [];
for (const block of blocks) {
const lines = block.trim().split("\n");
const timeLine = lines.find((l) => l.includes("-->"));
if (!timeLine) continue;
const [startStr, endStr] = timeLine.split("-->").map((s) => s.trim());
if (!startStr || !endStr) continue;
const text = lines
.slice(lines.indexOf(timeLine) + 1)
.join(" ")
.replace(/<[^>]+>/g, "") // strip HTML tags
.trim();
if (!text) continue;
words.push({
text,
start: parseVttTimestamp(startStr),
end: parseVttTimestamp(endStr),
});
}
return words;
}
// ---------------------------------------------------------------------------
// Timestamp helpers
// ---------------------------------------------------------------------------
/** Parse SRT timestamp: 00:01:23,456 → seconds */
function parseSrtTimestamp(ts: string): number {
const m = ts.match(/(\d+):(\d+):(\d+)[,.](\d+)/);
if (!m) return 0;
return (
parseInt(m[1]!, 10) * 3600 +
parseInt(m[2]!, 10) * 60 +
parseInt(m[3]!, 10) +
parseInt(m[4]!.padEnd(3, "0"), 10) / 1000
);
}
/** Parse VTT timestamp: 00:01:23.456 or 01:23.456 → seconds */
function parseVttTimestamp(ts: string): number {
const parts = ts.split(":");
if (parts.length === 3) return parseSrtTimestamp(ts);
// MM:SS.mmm
if (parts.length === 2) {
const [min, secMs] = parts;
const [sec, ms] = (secMs ?? "0.0").split(".");
return (
parseInt(min!, 10) * 60 + parseInt(sec!, 10) + parseInt((ms ?? "0").padEnd(3, "0"), 10) / 1000
);
}
return 0;
}
function round3(n: number): number {
return Math.round(n * 1000) / 1000;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Load and normalize a transcript file to a standard word array.
*
* Supports:
* - whisper.cpp JSON (--output-json-full with --dtw)
* - OpenAI Whisper API response (verbose_json with word timestamps)
* - SRT subtitle files (phrase-level, not word-level)
* - VTT subtitle files (phrase-level, not word-level)
* - Pre-normalized JSON array ([{text, start, end}])
*/
export function loadTranscript(filePath: string): { words: Word[]; format: TranscriptFormat } {
const ext = extname(filePath).toLowerCase();
const content = readFileSync(filePath, "utf-8");
if (ext === ".srt") return { words: parseSrt(content), format: "srt" };
if (ext === ".vtt") return { words: parseVtt(content), format: "vtt" };
// JSON formats — parse once, detect, then extract words
const parsed = JSON.parse(content);
const format = detectJsonFormat(parsed);
const words =
format === "whisper-cpp"
? parseWhisperCpp(parsed)
: format === "openai"
? parseOpenAI(parsed)
: (parsed as Word[]).map((w) => ({
text: w.text.trim(),
start: round3(w.start),
end: round3(w.end),
}));
return { words, format };
}
/**
* Patch caption HTML files in a project directory with transcript words.
* Replaces `const script = [...]` or `const TRANSCRIPT = [...]` in <script> blocks.
*/
export function patchCaptionHtml(dir: string, words: Word[]): void {
if (words.length === 0) return;
// Indent to 10 spaces to match typical composition script indentation
const wordsJson = JSON.stringify(words, null, 2).replace(/\n/g, "\n ");
let htmlFiles: string[];
try {
htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
.map((e) => join(e.parentPath ?? e.path, e.name));
} catch {
return;
}
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
const scriptBlocks = content.match(/<script>[\s\S]*?<\/script>/g) ?? [];
let scriptMatch: RegExpMatchArray | null = null;
let transcriptMatch: RegExpMatchArray | null = null;
for (const block of scriptBlocks) {
scriptMatch = scriptMatch ?? block.match(/const script = \[[\s\S]*?\];/);
transcriptMatch = transcriptMatch ?? block.match(/const TRANSCRIPT = \[[\s\S]*?\];/);
}
const match = scriptMatch ?? transcriptMatch;
if (match) {
const varName = scriptMatch ? "script" : "TRANSCRIPT";
content = content.replace(match[0], `const ${varName} = ${wordsJson};`);
writeFileSync(file, content, "utf-8");
}
}
}
+17 -15
View File
@@ -9,6 +9,7 @@ const VIDEO_EXTENSIONS = new Set([".mp4", ".webm", ".mov", ".mkv", ".avi"]);
export interface TranscribeOptions { export interface TranscribeOptions {
model?: string; model?: string;
language?: string;
onProgress?: (message: string) => void; onProgress?: (message: string) => void;
} }
@@ -125,21 +126,22 @@ export async function transcribe(
const outputBase = join(outputDir, "transcript"); const outputBase = join(outputDir, "transcript");
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
execFileSync( const whisperArgs = [
whisper.executablePath, "--model",
[ modelPath,
"--model", "--output-json-full",
modelPath, "--output-file",
"--output-json-full", outputBase,
"--output-file", "--dtw",
outputBase, model,
"--dtw", "--suppress-nst",
model, ];
"--suppress-nst", if (options?.language) {
wavPath, whisperArgs.push("--language", options.language);
], }
{ stdio: "ignore", timeout: 300_000 }, whisperArgs.push(wavPath);
);
execFileSync(whisper.executablePath, whisperArgs, { stdio: "ignore", timeout: 300_000 });
// 5. Read and validate output // 5. Read and validate output
const transcriptPath = `${outputBase}.json`; const transcriptPath = `${outputBase}.json`;
@@ -603,4 +603,126 @@ describe("template_literal_selector rule", () => {
const finding = result.findings.find((f) => f.code === "template_literal_selector"); const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding).toBeUndefined(); expect(finding).toBeUndefined();
}); });
// ── Caption lint rules ────────────────────────────────────────────────
it("warns when caption exit has no hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption exit has hard kill tl.set", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<div id="caption-container"></div>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function(group, gi) {
var groupEl = document.createElement("div");
groupEl.id = "cg-" + gi;
tl.set(groupEl, { opacity: 1 }, group.start);
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
});
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
text-align: center;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
it("does not warn when caption group has nowrap with max-width", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: absolute;
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
);
expect(finding).toBeUndefined();
});
it("warns when caption container uses position: relative", () => {
const html = `
<html><body>
<div data-composition-id="captions" data-width="1920" data-height="1080">
<style>
.caption-group {
position: relative;
}
</style>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
window.__timelines["captions"] = tl;
</script>
</div>
</body></html>`;
const result = lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
});
}); });
@@ -710,6 +710,79 @@ export function lintHyperframeHtml(
} }
} }
// ── Caption lint rules ──────────────────────────────────────────────────
// Rule: caption_exit_missing_hard_kill
// Exit tweens (tl.to with opacity: 0) can fail when karaoke word-level tweens
// conflict, leaving captions stuck on screen. A hard tl.set kill is needed.
for (const script of scripts) {
const content = script.content;
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
const hasHardKill =
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(content);
const hasCaptionLoop =
/forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content);
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
pushFinding({
code: "caption_exit_missing_hard_kill",
severity: "warning",
message:
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
fixHint:
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
"exit tl.to animation as a deterministic kill.",
});
}
}
// Rule: caption_text_overflow_risk
// Captions with nowrap text and no max-width will clip off-screen.
for (const style of styles) {
const content = style.content;
const captionBlocks = content.matchAll(
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
const hasMaxWidth = /max-width/i.test(body);
if (hasNowrap && !hasMaxWidth) {
pushFinding({
code: "caption_text_overflow_risk",
severity: "warning",
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
fixHint:
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
});
}
}
}
// Rule: caption_container_relative_position
// position: relative on caption containers causes overflow and stacking issues.
for (const style of styles) {
const content = style.content;
const captionBlocks = content.matchAll(
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
);
for (const [, selector, body] of captionBlocks) {
if (!body) continue;
if (/position\s*:\s*relative/i.test(body)) {
pushFinding({
code: "caption_container_relative_position",
severity: "warning",
selector: (selector ?? "").trim(),
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
fixHint: "Use position: absolute for all caption elements.",
});
}
}
}
// ── External CDN script dependency check ──────────────────────────────── // ── External CDN script dependency check ────────────────────────────────
// Compositions that load CDN libraries via <script src="https://..."> work // Compositions that load CDN libraries via <script src="https://..."> work
// correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent // correctly in bundled mode (bundleToSingleHtml auto-hoists them to the parent
+138 -28
View File
@@ -1,6 +1,7 @@
--- ---
name: hyperframes-captions name: hyperframes-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. 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, subtitles, or lyrics to a HyperFrames composition. Lyric videos ARE captions — any text synced to audio uses this skill.
trigger: Use this skill whenever a task involves syncing text to audio timing. This includes captions, subtitles, lyrics, karaoke, transcription overlays, and any word-level or phrase-level text timed to speech or music.
--- ---
# Captions # Captions
@@ -9,41 +10,113 @@ Analyze the spoken content to determine caption style. If the user specifies a s
## Transcript Source ## Transcript Source
The project's `transcript.json` contains word-level timestamps from whisper.cpp (`--output-json-full` with `--dtw`): The project's `transcript.json` contains a normalized word array with word-level timestamps:
```json ```json
{ [
"transcription": [ { "text": "Hello", "start": 0.0, "end": 0.5 },
{ { "text": "world.", "start": 0.6, "end": 1.2 }
"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: This is the only format the captions composition consumes. Use it directly:
```js ```js
const words = []; const words = JSON.parse(transcriptJson); // [{ text, start, end }]
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). ### 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.en` | 75 MB | Fastest | Low | Quick previews, testing pipeline |
| `base.en` | 142 MB | Fast | Fair | Short clips, clear audio |
| `small.en` | 466 MB | Moderate | Good | **Default** — good for most content |
| `medium.en` | 1.5 GB | Slow | Very good | Important content, noisy audio, music |
| `large-v3` | 3.1 GB | Slowest | Best | Multilingual, production captions |
`.en` models are English-only and more accurate for English. Drop the `.en` suffix for multilingual (e.g., `medium` instead of `medium.en`).
**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.
### 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, ask the user to provide one or run:
```bash
npx hyperframes transcribe <audio-or-video-file>
```
3. If transcription quality is poor (words at wrong times, gibberish), suggest upgrading the model:
```bash
npx hyperframes transcribe audio.mp3 --model medium.en
```
## Style Detection (Default — When No Style Is Specified) ## Style Detection (Default — When No Style Is Specified)
@@ -130,9 +203,46 @@ Break groups on sentence boundaries (`.` `?` `!`), pauses (>150ms gap), or max w
- Use `position: absolute` — never relative (causes overflow) - Use `position: absolute` — never relative (causes overflow)
- One caption group visible at a time - One caption group visible at a time
## Text Overflow Prevention
Captions must never clip off-screen. Apply these rules:
- Set `max-width: 1600px` (landscape) or `max-width: 900px` (portrait) on caption container
- Add `overflow: hidden` as a safety net
- **Auto-scale font size** based on character count:
- ≤18 chars → full size (e.g., 78px)
- 1925 chars → reduce ~15% (e.g., 68px)
- 26+ chars → reduce ~25% (e.g., 58px)
- Reduce `letter-spacing` for long text (switch from `-0.02em` to `-0.04em`)
- Give the caption container an explicit `height` (e.g., `200px`) — don't rely on content sizing with absolute children
- Use `position: absolute` on all caption elements — `position: relative` causes overflow
## Caption Exit Guarantee
Captions that stick on screen are the most common caption bug. Every caption group **must** have a hard kill after its exit animation.
**The pattern:**
```js
// Animate exit (soft — can fail if tweens conflict)
tl.to(groupEl, { opacity: 0, scale: 0.95, duration: 0.12, ease: "power2.in" }, group.end - 0.12);
// Hard kill at group.end (deterministic — guarantees invisible)
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
```
**Why both?** The `tl.to` exit can fail to fully hide a group when:
- Karaoke word-level tweens (`scale`, `color`) on child elements conflict with the parent exit tween
- `fromTo` entrance tweens lock start/end values that override later tweens on the same property
- Timeline scrubbing lands between the exit start and end
The `tl.set` at `group.end` is a deterministic kill — it fires at an exact time, doesn't animate, and can't be overridden by other tweens at different times.
## Constraints ## Constraints
- **Deterministic.** No `Math.random()`, no `Date.now()`. - **Deterministic.** No `Math.random()`, no `Date.now()`.
- **Sync to transcript timestamps.** Words appear when spoken. - **Sync to transcript timestamps.** Words appear when spoken.
- **One group visible at a time.** No overlapping caption groups. - **One group visible at a time.** No overlapping caption groups.
- **Every caption group must have a hard `tl.set` kill at `group.end`.** Exit animations alone are not sufficient.
- **Check project root** for font files before defaulting to Google Fonts. - **Check project root** for font files before defaulting to Google Fonts.