Remotion @remotion/openai-whisper 详解:将 OpenAI Whisper 转写结果转换为 Captions 字幕数据
@remotion/openai-whisper 是 Remotion 生态中处理语音转写字幕的轻量工具包,其核心职责是:接收 OpenAI Whisper API 返回的 verbose JSON 转写结果(带逐词时间戳),将其转换为 Remotion 标准 Caption[] 字幕数组,供后续字幕时间轴处理与视频渲染使用。读完本篇,你将掌握该包的完整安装方式、输入/输出数据结构、openAiWhisperApiToCaptions() 的转换算法原理(含标点归属、撇号变体、连字符拆词等边界处理),以及一套可直接复制的 OpenAI SDK 调用示例与测试用例验证方式。
包定位与安装
该包的定位可以用一句话概括(来自 README):"Work with the output of the OpenAI Whisper API",即它不直接调用 Whisper API,而是处理 API 的输出结果。包的元信息定义在 package.json:包名为 @remotion/openai-whisper,当前仓库版本为 4.0.521,MIT 协议,运行时仅依赖 @remotion/captions(用于 Caption 类型),测试则基于 bun test src 运行。
安装命令
npm install @remotion/openai-whisper --save-exact
README 中特别强调了版本对齐原则,安装 Remotion 包时必须让项目中所有 remotion 与 @remotion/* 包保持同一版本:去掉版本号前的 ^ 字符,锁定为精确版本(这也是上面使用 --save-exact 参数的原因)。
包入口 src/index.ts 对外导出 3 个 API 与 1 个类型:
export {
OpenAiToCaptionsInput,
OpenAiToCaptionsOutput,
openAiWhisperApiToCaptions,
} from './openai-whisper-api-to-captions';
export {OpenAiVerboseTranscription} from './openai-format';
输入格式:OpenAiVerboseTranscription
转换函数的输入类型在 openai-format.ts 中定义,它对应 OpenAI Whisper API 在 response_format: 'verbose_json' 且 timestamp_granularities: ['word'] 时的返回结构:
| 字段 | 类型 | 必填性 | 说明 |
|---|---|---|---|
duration |
number | string |
必填 | 音频时长(API 可能返回字符串) |
language |
string |
必填 | 识别出的语言,如 english |
text |
string |
必填 | 完整转写文本,转换算法以此为匹配基准 |
task |
'transcribe' |
可选 | 任务类型,非 transcribe 时函数会抛错 |
words |
TranscriptionWord[] |
可选(本包实际必需) | 逐词时间戳数组 |
segments |
TranscriptionSegment[] |
可选 | 分段信息,本包转换未直接使用 |
其中逐词结构 TranscriptionWord 只有三个字段,时间单位为秒(浮点数):
export interface TranscriptionWord {
end: number;
start: number;
word: string;
}
TranscriptionSegment 则包含 Whisper verbose 格式的完整分段元数据(id、avg_logprob、compression_ratio、no_speech_prob、seek、temperature、tokens 等),从源码结构看它只是为类型完整性而声明,转换逻辑并不消费分段数据。
核心 API:openAiWhisperApiToCaptions
函数签名定义在 openai-whisper-api-to-captions.ts:
export type OpenAiToCaptionsInput = {
transcription: OpenAiVerboseTranscription;
};
export type OpenAiToCaptionsOutput = {
captions: Caption[];
};
export const openAiWhisperApiToCaptions = ({
transcription,
}: OpenAiToCaptionsInput): OpenAiToCaptionsOutput => {
// ...
};
端到端使用示例
测试文件 get-and-convert.test.ts 中给出了真实的 API 调用路径(该用例在 CI 环境外会实际调用 OpenAI API,使用仓库内 dialogue.wav 作为音频素材),整理后完整流程如下:
import fs from 'fs';
import OpenAI from 'openai';
import {openAiWhisperApiToCaptions} from '@remotion/openai-whisper';
const openai = new OpenAI();
// 1. 调用 Whisper API,必须要求 word 级时间戳
const transcription = await openai.audio.transcriptions.create({
file: fs.createReadStream('./dialogue.wav'),
model: 'whisper-1',
response_format: 'verbose_json',
prompt: 'Hello, welcome to my lecture.',
timestamp_granularities: ['word'], // 关键:缺少此参数本包无法工作
});
// 2. 转换为 Remotion Caption[]
const {captions} = openAiWhisperApiToCaptions({transcription});
timestamp_granularities: ['word'] 是硬性前提:如果不带 words 字段,转换函数会直接抛出 The transcription does need to be been generated with 'timestamp_granularities: ["word"]' 错误(见 源码 L34-L44)。
转换结果示例
基于测试夹具 output.ts 中一段 170 秒的播客真实转写,转换结果的前几条为:
{
captions: [
{confidence: null, endMs: 7039.999961853027, startMs: 6519.999980926514, text: "What's", timestampMs: 6779.9999713897705},
{confidence: null, endMs: 7559.999942779541, startMs: 7039.999961853027, text: ' up,', timestampMs: 7299.999952316284},
{confidence: null, endMs: 7880.000114440918, startMs: 7619.999885559082, text: ' everybody?', timestampMs: 7750},
{confidence: null, endMs: 8300.000190734863, startMs: 8239.999771118164, text: ' This', timestampMs: 8269.999980926514},
// ...
],
}
注意一个易被忽略的细节:Whisper 的 words 只有秒级起止时间,而 text 字段(完整转写文本)承载了标点信息。转换算法的工作就是把两者对齐,让每条 Caption 的 text 带上它"拥有"的标点(如 "What's" 后的逗号归到 up,)。
输出结构:Caption 类型
输出元素的类型 Caption 来自依赖包 @remotion/captions,定义在 caption.ts:
export type Caption = {
text: string;
startMs: number;
endMs: number;
timestampMs: number | null;
confidence: number | null;
pageBreakAfter?: boolean;
};
本包的填充规则(见 源码 L70-L76):
startMs/endMs:word.start、word.end乘以 1000,从秒转为毫秒;timestampMs:取(start + end) / 2的中点时刻(毫秒),用于在渲染层定位字幕显示时机;confidence:固定为null——Whisper 的逐词数据本身不携带逐词置信度(分段级的avg_logprob未被采用),因此该字段留空;text:不是直接取word.word,而是匹配算法从完整文本中切出的片段(含标点,见下节)。
转换算法原理:剩余文本扫描与标点归属
理解这个包的关键在于它不是简单地"一个 word 生成一条 Caption"。完整算法在 openai-whisper-api-to-captions.ts 中,可以拆解为五步:
1. 前置校验
if (!transcription.words) {
if (transcription.task && transcription.task !== 'transcribe') {
throw new Error(`The transcription does need to be a "transcribe" task. ...`);
}
throw new Error('The transcription does need to be been generated with `timestamp_granularities: ["word"]`');
}
先区分"任务类型错误"与"缺少词级时间戳"两种错误,给出可诊断的报错信息。
2. 首词修剪(issue #5031)
if (firstWord) {
word.word = word.word.trimStart();
}
某些(第三方/兼容型)Whisper API 会在第一个词前面多带一个空格(如 " Hello")。测试文件 foreign-api.test.ts 就是针对这一类输入:每个词都带前导空格的转写结果,最终首条 Caption 的 text 被规整为 'Hello' 而非 ' Hello'。
3. 逐词构建正则,在"剩余文本"中匹配
算法维护一个 remainingText(初始为 transcription.text),对每个词构造如下正则(见 源码 L56-L68):
const punctuation = `\\?,\\.\\%\\–\\!\\;\\:\\'\\\"\\-\\_\\(\\)\\[\\]\\{\\}\\@\\#\\$\\^\\&\\*\\+\\=\\/\\|\\<\\>\\~\`\\u2018\\u2019\\u02bc\\uff07`;
const wordToMatch = word.word.replace(new RegExp(`^[${punctuation}]+`), '');
const match = new RegExp(
`^([\\s?${punctuation}]{0,4})${escapeWordForRegex(wordToMatch)}([${punctuation}]{0,3})?`,
).exec(remainingText);
正则的三个组成部分各有用意:
- 前导组
([\s?<标点>]{0,4}):允许词前最多 4 个空白/标点字符(空格、逗号、连字符、货币符号等),这些字符归属于当前词——例如" up,"的前导空格、"It's"后面的词' up,'携带的逗号; - 词本体:
escapeWordForRegex对词做正则转义,但有一个特殊处理——撇号的 5 种 Unicode 变体(U+0027直引号、U+2018、U+2019弯引号、U+02BC修饰符撇号、U+FF07全角)在词与文本之间互相等价匹配,见 源码 L16-L27。这解决了 issue #7298:Whisper 有时在words里用弯引号Let's,在text里用直引号Let's,regressions.test.ts 的 "Issue 7298 - apostrophe variants" 用例专门验证了这一点; - 后缀组
([标点]{0,3})?:词后最多 3 个标点,归入该词的text——如句末的.、?、!、百分号%(回归用例 "it is 99% better" 中99的 Caption 文本是' 99%')。
匹配成功后,remainingText 从匹配片段末尾继续,保证每个词只消费一次文本,且整体是顺序推进的。
4. 匹配失败即抛错
如果某个词在剩余文本中找不到,函数会抛出携带上下文的错误(源码 L61-L65),提示词是什么、剩余文本前 100 字符是什么,并引导用户提交 issue。这是"快速失败"设计:宁可直接报错,也不产出时间戳错位的字幕。
5. 生成 Caption 并推进
匹配片段 match[0] 直接作为 text 写入 Caption,时间字段按前文规则换算为毫秒。
边界情况与测试证据
包内 7 个测试文件(src/test/)恰好覆盖了转换算法的所有难点分支,是验证各边界行为最直接的证据:
| 测试文件 | 覆盖场景 | 关键断言 |
|---|---|---|
| issue-50069.test.ts | 连字符拆词 "Like-minded."、千分位数字 "50,000." |
Like 与 -minded. 各自成条;50, 与 000. 各自成条——前导 -、, 通过前导标点组归入后续词 |
| partial-word.test.ts | 词间时间间隙、句末标点 | massive 的 Caption 为 ' massive.',句号由后缀组捕获 |
| regressions.test.ts | 99%、real-time 连字符、撇号变体 |
99% 中 % 归后缀;real 后跟 - 时 - 归入后续 time 词;Let's(U+2019)能匹配文本中的 Let's(U+0027) |
| special-chars.test.ts | 货币符号 $500 |
$ 经前导标点组归入当前词,最终文本为 ' $500' |
| foreign-api.test.ts | 第三方 API 词带前导空格 | 首词 trimStart 后输出 'Hello',其余词的空格被前导组吸收 |
| get-and-convert.test.ts | 完整真实转写(170 秒播客)+ 真实 API 调用 | 前 10 条 Caption 精确断言;非 CI 环境实际请求 whisper-1 并断言 captions.length > 60 |
这些用例说明该函数设计目标非常明确:容错地吸收 Whisper(含兼容 API)输出中的空格、标点、Unicode 变体差异,但拒绝在无法确定归属时静默产出错误数据。
与 Remotion 字幕体系的衔接
Caption[] 是 Remotion 字幕体系的通用中间格式:本包通过 @remotion/captions(package.json 中声明的 workspace 依赖)导入该类型,保证输出与 @remotion/captions 的时间轴工具(如字幕分句、分页、时间轴计算)类型兼容。一条典型的数据流是:
音频文件 → OpenAI Whisper API (verbose_json + word 时间戳)
→ openAiWhisperApiToCaptions()
→ Caption[] (毫秒级, 带标点)
→ @remotion/captions 时间轴工具 / 你的 React 组件渲染
使用前提与限制
结合源码与测试,使用该包时需要满足以下前提:
- 必须带词级时间戳:API 调用需设置
response_format: 'verbose_json'与timestamp_granularities: ['word'],且任务为transcribe,否则函数抛错; text与words必须来自同一次转写:算法依赖两者字符级对齐,文本对不上会触发匹配失败异常;- 置信度不可得:输出的
confidence恒为null,若需要置信度应另行使用分段级数据; - 版本锁定:与项目中其他
remotion/@remotion/*包保持同一精确版本(如当前仓库版本4.0.521)。
关键文件索引
- packages/openai-whisper/README.md — 包说明与安装方式
- packages/openai-whisper/package.json — 版本、依赖与测试脚本
- packages/openai-whisper/src/index.ts — 导出入口
- packages/openai-whisper/src/openai-format.ts — 输入类型定义
- packages/openai-whisper/src/openai-whisper-api-to-captions.ts — 核心转换实现
- packages/captions/src/caption.ts —
Caption输出类型 - packages/openai-whisper/src/test/ — 边界行为测试用例
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00