mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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:
co-authored by
Claude Opus 4.6
parent
7b18c0352e
commit
2f99e33bbe
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user