reelcn
Tools

Tools

Transcribe

Turns audio or video into word-level captions JSON for the caption components.

Install

Usage

node scripts/reelcn-transcribe.ts talk.mp4
node scripts/reelcn-transcribe.ts talk.mp4 --openai --out public/captions/talk.json
node scripts/reelcn-transcribe.ts subtitles.srt

Use

  • Captioning a talking-head short, a podcast clip or a tutorial
  • Converting an existing .srt into the caption format

Dependencies

@remotion/captions, @remotion/install-whisper-cpp, @remotion/openai-whisper

Source

registry/tools/transcribe.ts
/**
 * @title Transcribe
 * @category tools
 * @description Turns audio or video into word-level captions JSON for the caption components.
 * @env OPENAI_API_KEY
 * @use Captioning a talking-head short, a podcast clip or a tutorial
 * @use Converting an existing .srt into the caption format
 * @example
 * node scripts/reelcn-transcribe.ts talk.mp4
 * node scripts/reelcn-transcribe.ts talk.mp4 --openai --out public/captions/talk.json
 * node scripts/reelcn-transcribe.ts subtitles.srt
 */
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parseSrt } from "@remotion/captions";
import {
  downloadWhisperModel,
  installWhisperCpp,
  toCaptions,
  transcribe,
  type WhisperModel,
} from "@remotion/install-whisper-cpp";
import { openAiWhisperApiToCaptions } from "@remotion/openai-whisper";

const args = process.argv.slice(2);
const input = args.find((arg) => !arg.startsWith("--"));
const flag = (name: string) => args.find((arg) => arg.indexOf(`--${name}=`) === 0)?.slice(name.length + 3);

if (!input) {
  console.error(
    "usage: node scripts/reelcn-transcribe.ts <audio|video|srt> [--model=small.en] [--openai] [--out=path.json]",
  );
  process.exit(1);
}

const out = flag("out") ?? path.join("public", "captions", `${path.basename(input).replace(/\.[^.]+$/, "")}.json`);
const model = (flag("model") ?? "small.en") as WhisperModel;
const whisperCppVersion = "1.5.5";
const whisperDir = path.join(".reelcn", "whisper");
mkdirSync(path.dirname(out), { recursive: true });

const write = (captions: unknown) => {
  writeFileSync(out, `${JSON.stringify(captions, null, 2)}\n`);
  console.log(`${out}: ${(captions as unknown[]).length} captions`);
};

if (input.endsWith(".srt")) {
  // .srt has no word timings, so each subtitle line becomes one caption.
  const { captions } = parseSrt({ input: readFileSync(input, "utf8") });
  write(captions);
} else if (args.indexOf("--openai") !== -1) {
  const key = process.env.OPENAI_API_KEY;
  if (!key) throw new Error("--openai needs OPENAI_API_KEY in the environment");
  const body = new FormData();
  body.append("file", new Blob([readFileSync(input)]), path.basename(input));
  body.append("model", "whisper-1");
  body.append("response_format", "verbose_json");
  body.append("timestamp_granularities[]", "word");
  const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
    method: "POST",
    headers: { Authorization: `Bearer ${key}` },
    body,
  });
  if (!response.ok) throw new Error(`OpenAI: ${response.status} ${await response.text()}`);
  const { captions } = openAiWhisperApiToCaptions({ transcription: await response.json() });
  write(captions);
} else {
  // whisper.cpp wants 16kHz mono wav; Remotion ships the ffmpeg that makes one.
  const wav = path.resolve(".reelcn", `${path.basename(input).replace(/\.[^.]+$/, "")}-16k.wav`);
  mkdirSync(path.dirname(wav), { recursive: true });
  execFileSync("npx", ["remotion", "ffmpeg", "-y", "-i", input, "-ar", "16000", "-ac", "1", wav], { stdio: "inherit" });

  await installWhisperCpp({ to: whisperDir, version: whisperCppVersion });
  await downloadWhisperModel({ model, folder: whisperDir });
  const whisperCppOutput = await transcribe({
    inputPath: wav,
    whisperPath: whisperDir,
    whisperCppVersion,
    modelFolder: whisperDir,
    model,
    tokenLevelTimestamps: true,
  });
  const { captions } = toCaptions({ whisperCppOutput });
  write(captions);
}

Related