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
+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);
}
}