12k
All articles

在浏览器中借助 AI 为视频添加字幕

使用Transformers.js和Whisper在浏览器中生成视频字幕。提取音频,通过WebGPU或WASM处理并创建WebVTT字幕,媒体文件无需上传。

OpenReplay Team
OpenReplay Team
在浏览器中借助 AI 为视频添加字幕

Transformers.js 可以直接在浏览器中运行 OpenAI 的 Whisper 语音识别模型,因此无需上传文件、无需 API key、也无需服务器,就能把视频音频转写成字幕。

大多数字幕教程一上来就要求你把用户的视频发送到某个转写 API,并按分钟付费。如果视频是私密内容,或者你只是不想再多一笔账单、再多维护一个后端,这种方案就行不通了。

本指南将整条流水线都构建在客户端:解码音频,在 Web Worker 中运行 Whisper(支持从 WebGPU 回退到 WASM),将输出格式化为合法的 WebVTT,并把结果挂载到 <video> 元素上。

核心要点

  • transformers.js 中的 Whisper 要求输入为 16 kHz 单声道音频,类型为 Float32Array,因此视频的音轨必须先解码并重采样,才能进行转写。
  • 存在 navigator.gpu 并不意味着 WebGPU 一定可用。只有当 navigator.gpu.requestAdapter() 返回 adapter 时才使用 device: "webgpu",否则回退到 WASM。
  • 在 Web Worker 中运行转写流水线。否则,模型加载和推理会在整个任务期间阻塞主线程。
  • WebVTT 文件以 WEBVTT 开头,cue 之间用空行分隔,时间戳采用 hh:mm:ss.ttt 格式,cue 文本中的 & 和 < 需要转义。
  • 浏览器端 Whisper 适合处理几分钟长的片段。对于长达一小时的录音或批量媒体,请使用托管的转写服务。

要构建什么,以及为什么数据始终留在本地

最终完成的浏览器端字幕应用包含四个部分:一个文件输入框、一个 <video> 元素、一个运行 Whisper 的 worker,以及一个作为字幕轨道挂载的、动态生成的 .vtt 文件。媒体文件从用户磁盘读入内存,始终不会离开当前标签页。唯一的网络流量是从 Hugging Face Hub 一次性下载模型,以及 ONNX Runtime 的 WASM 文件(除非你自行托管,否则 transformers.js 默认从 CDN 加载它们)。任何音频或视频都不会被发送到别处。

本文不涉及 WebCodecs、内嵌(烧录)字幕或 MP4 导出。字幕始终作为独立的文本轨道,由浏览器叠加渲染在视频上方。帧级别的处理属于基于 WebCodecs 构建的实时视频处理流水线的范畴,那是另一项工作。

如何从视频中提取音轨?

transformers.js 中的 Whisper 接收采样率为 16 kHz 的单声道音频,类型为 Float32Array。该 pipeline 接受以类型化数组形式传入的原始音频,并默认其采样率已经正确。它不会做任何校验,因此任何其他采样率的音频都会产生错误的转写结果,而且不会报错。

decodeAudioData() 用于解码文件中的音频。对于视频文件,能否成功解码取决于浏览器支持哪些容器格式和编解码器,因此一个在某个浏览器中能正常播放的 MP4,在另一个浏览器中可能解码失败。接下来,通过一个以单声道、16,000 Hz 创建的 OfflineAudioContext 渲染解码后的缓冲区。Web Audio 会将音源缩混以适配单声道的输出目标,并重采样至该 context 的采样率,因此一次渲染即可同时完成两种转换。

async function extractAudio(file) {
  const arrayBuffer = await file.arrayBuffer();
  const decodeCtx = new AudioContext();
  const decoded = await decodeCtx.decodeAudioData(arrayBuffer);
  await decodeCtx.close();

  const targetRate = 16000;
  const offline = new OfflineAudioContext(
    1,
    Math.ceil(decoded.duration * targetRate),
    targetRate
  );
  const source = offline.createBufferSource();
  source.buffer = decoded;
  source.connect(offline.destination);
  source.start();
  const rendered = await offline.startRendering();
  return { audio: rendered.getChannelData(0), duration: decoded.duration };
}

请保留 duration,后面的格式化函数会用到它。

在 Web Worker 中运行 Transformers.js Whisper

调用 transformers.js 的 ASR pipeline 时传入 return_timestamps: true,输出中就会包含一个 chunks 数组,其中每一项都有一个 text 字符串和一个以秒为单位的 timestamp: [start, end] 数对。将 pipeline 放在 Web Worker 中运行,可以让页面保持响应:否则,模型加载和推理会在整个任务期间阻塞主线程。

使用 npm install @huggingface/transformers 安装依赖包。worker 只创建一次 pipeline,之后的每个文件都复用它:

// worker.js
import { pipeline } from "@huggingface/transformers";

let transcriberPromise;

async function pickDevice() {
  if (!("gpu" in navigator)) return "wasm";
  try {
    const adapter = await navigator.gpu.requestAdapter();
    return adapter ? "webgpu" : "wasm";
  } catch {
    return "wasm";
  }
}

function getTranscriber() {
  transcriberPromise ??= pickDevice().then((device) =>
    pipeline("automatic-speech-recognition", "onnx-community/whisper-tiny.en", { device })
  );
  return transcriberPromise;
}

self.onmessage = async ({ data }) => {
  try {
    const transcriber = await getTranscriber();
    const output = await transcriber(data.audio, {
      return_timestamps: true,
      chunk_length_s: 30,
      stride_length_s: 5,
    });
    self.postMessage({ type: "done", chunks: output.chunks });
  } catch (err) {
    self.postMessage({ type: "error", message: String(err) });
  }
};

在主线程中,通过转移(transfer)缓冲区而非复制的方式将采样数据发送给 worker。new URL(..., import.meta.url) 写法是 Vite 能够识别的 worker 模式,webpack 5 同样支持:

const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });

function transcribe(audio) {
  return new Promise((resolve, reject) => {
    worker.onmessage = ({ data }) =>
      data.type === "done" ? resolve(data.chunks) : reject(new Error(data.message));
    worker.postMessage({ audio }, [audio.buffer]);
  });
}

Whisper 以 30 秒为窗口进行处理。如果省略 chunk_length_s,transformers.js 只会保留音频的前 30 秒并输出一条警告,视频的其余部分将没有字幕。设置 chunk_length_s: 30 和 stride_length_s: 5 后,pipeline 会把较长的音频切分成相互重叠的 30 秒片段,并将结果合并为一组带时间戳的 chunk。

WebGPU 与 WASM:在加载前选定设备

只有当 navigator.gpu.requestAdapter() 返回 adapter 时,才应让 Transformers.js 在 WebGPU 上运行 Whisper,否则使用 WASM。切换到 GPU 只需在加载模型时设置一个 pipeline 选项:device: "webgpu"。不过,存在 navigator.gpu 并不能保证 WebGPU 可用。应调用 requestAdapter()(它可能 resolve 为 null),仅在返回了 adapter 时才选择 WebGPU。navigator.gpu 在 worker 中同样可用,因此上文的 pickDevice() 可以在 worker 中运行。

条件设备用户体验
不存在 navigator.gpu"wasm"转写较慢,仅使用 CPU
存在 navigator.gpu,但 requestAdapter() 返回 null"wasm"转写较慢,仅使用 CPU
返回了 adapter"webgpu"GPU 加速推理

在真实设备上,adapter 为 null 的情况很常见。Chrome 的 WebGPU 故障排查指南列出了常见原因:用户在设置中关闭了图形加速、GPU 位于 Chrome 的屏蔽列表中、该平台尚不支持 WebGPU,或者 Chrome 根本找不到 GPU。像 pickDevice() 那样在创建 pipeline 之前自行检查 adapter,即可覆盖以上所有情况。

根据 Hugging Face 的指南,截至 2026 年 3 月,WebGPU 的全球支持率约为 85%。WebGPU 实现状态页面显示:在 Windows、macOS 和 ChromeOS 上,Chrome 113 及以上版本默认启用;在大多数 Android 设备上,Chrome 121 及以上版本默认启用。在 Linux 上,Chrome 仅针对部分 GPU 启用。Firefox 自 141 版起在 Windows 上默认启用,自 147 版起在 Apple Silicon Mac 上默认启用。Safari 26 在 macOS、iOS、iPadOS 和 visionOS 上均已支持。Android 版 Firefox 仍默认关闭:需要使用 Firefox Beta 或 Nightly,并在 about:config 中开启 gfx.webgpu.ignore-blocklist 设置。

如何将 Whisper chunk 转换为 WebVTT?

每个 Whisper chunk 对应一个 WebVTT cue:其开始和结束时间写在时间行上,文本写在下一行。MDN 的 WebVTT 参考文档给出了相关规则:文件以 WEBVTT 开头,cue 之间用一个空行分隔,用 --> 连接的两个时间戳决定每个 cue 的显示时段。

下面的格式化函数始终输出完整的 hh:mm:ss.ttt 格式。小时至少两位数,分钟和秒均不超过 59,毫秒部分始终为三位数。此外,每个 cue 的结束时间都必须晚于开始时间。cue 文本中有两个字符不能原样出现:& 和 <。代码先将 & 替换为 &amp;,再将 < 替换为 &lt;。MDN 还建议将 > 写作 &gt;,代码也一并做了处理。

const pad = (n, width = 2) => String(n).padStart(width, "0");

function toVttTime(seconds) {
  const total = Math.round(seconds * 1000);
  const h = Math.floor(total / 3_600_000);
  const m = Math.floor((total % 3_600_000) / 60_000);
  const s = Math.floor((total % 60_000) / 1000);
  const ms = total % 1000;
  return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`;
}

function escapeCueText(text) {
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

function chunksToVtt(chunks, duration) {
  const cues = [];
  for (const { text, timestamp } of chunks) {
    const [start, end] = timestamp;
    const stop = end ?? duration;
    const body = escapeCueText(text.replace(/\s+/g, " ").trim());
    if (!body || stop <= start) continue;
    cues.push(`${toVttTime(start)} --> ${toVttTime(stop)}\n${body}`);
  }
  return `WEBVTT\n\n${cues.join("\n\n")}\n`;
}

这里有几个值得注意的细节:

  • toVttTime 先四舍五入到整毫秒,再拆分为时、分、秒,因此永远不会输出 .1000。
  • 先转义 &,是因为其他替换会引入新的 & 符号,如果最后才转义 &,就会导致重复转义。
  • 合并空白字符会去掉模型文本中的所有换行符。cue 内容中如果出现空行,会导致该 cue 提前结束。
  • ?? duration 回退是一项预防措施,以防最后一个 chunk 返回时没有结束时间。

格式化函数的输出如下(仅作示例):

WEBVTT

00:00:00.000 --> 00:00:04.320
Welcome back. Today we are wiring up captions.

00:00:04.320 --> 00:00:09.100
The model runs in a worker &amp; nothing is uploaded.

有了 VTT 文件后,你还可以将字幕翻译成其他语言。

通过 Blob URL 挂载字幕

要显示字幕,先将 VTT 字符串包装成类型为 text/vtt 的 Blob,为其创建 object URL,再将该 URL 用作 <track> 元素的 src。为该轨道设置 kind="captions"、srclang 和 default。Blob 构造函数会将 JavaScript 字符串编码为 UTF-8,这正是 WebVTT 所要求的编码。

function attachCaptions(video, vtt) {
  const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
  const track = document.createElement("track");
  track.kind = "captions";
  track.srclang = "en";
  track.label = "English (auto-generated)";
  track.src = url;
  track.default = true;
  video.append(track);
  track.track.mode = "showing";
  return () => URL.revokeObjectURL(url);
}

srclang 设为 "en",是因为 whisper-tiny.en 是一个仅支持英语的模型。由于该轨道是在视频加载完成之后才添加的,因此需要显式地将 mode 设为 "showing",使其立即显示。在替换或移除视频时,调用返回的函数以释放 object URL。在 React 中,同样的轨道放在你渲染的 <video> 内部即可,具体可参见在 React 中嵌入视频和使用 React 构建视频播放器。

浏览器端 Whisper 有哪些局限?

浏览器端 Whisper 处理几分钟长的片段效果很好,但处理长达一小时的录音则表现不佳。

  • 下载体积。 首次运行时需要下载模型权重,根据所选变体不同,体积从几十 MB 到几百 MB 不等。whisper-tiny.en 模型页面提供了其文件链接。Hugging Face Hub 上更大的 Whisper 变体准确率更高,但体积也更大,切换前请先查看各自的 model card。
  • 速度。 长文件处理较慢,WASM 路径下尤其如此。几分钟的音频没有问题,一小时的音频则会长时间占用标签页。
  • 内存。 完整的解码音频会以类型化数组的形式保存在内存中。
  • 批量或长时媒体。 对于长达一小时的录音或批处理任务,托管的转写服务是更好的选择。

总结

你完全可以在浏览器中为短视频生成字幕:解码并重采样音频,在 worker 中使用 WebGPU(没有可用 adapter 时使用 WASM)运行 Whisper,将 chunk 格式化为 WebVTT,再将文件作为基于 Blob 的轨道挂载上去。建议先用 whisper-tiny.en 处理一段两分钟的片段,并对照音频检查 cue 的时间是否准确。只有在准确率不够、且用户能够接受更大下载体积时,才换用 Hub 上更大的模型。

常见问题

transformers.js 会在每次加载页面时都下载 Whisper 模型吗?

不会。首次运行时,transformers.js 会下载模型文件并存入浏览器缓存,之后的加载会直接从缓存读取,而不再走网络。该行为由 env.useBrowserCache 设置控制。在 transformers.js v4 中,ModelRegistry.is_pipeline_cached 可用于判断某个 pipeline 的文件是否已被缓存,ModelRegistry.clear_pipeline_cache 则用于清除这些缓存。

在 worker 中加载 Whisper 时,如何显示模型下载进度?

在 pipeline 选项中(与 device 并列)传入一个 progress_callback 函数。每个模型文件下载时,transformers.js 都会调用它并传入状态更新。在 worker 内部,使用 postMessage 将每条更新转发到主线程,并在主线程中渲染进度条。transformers.js v4 新增了 progress_total 事件,可报告整体加载进度,这样你就无需自己汇总各个文件的进度了。

能为非英语视频生成字幕吗?

可以,但需要使用多语言 Whisper checkpoint,而不是以 .en 结尾的纯英语模型。在调用 transcriber 时传入 language 和 task,例如 language: 'french' 搭配 task: 'transcribe'。将 task 设为 'translate' 时,Whisper 会把其他语言的语音转换为英文文本。轨道的 srclang 应设置为字幕文本的语言,而不是音频的语言。

字幕应用能否在不访问 Hugging Face Hub 或 CDN 的情况下运行?

可以,前提是所有文件都自行托管。将 env.allowRemoteModels 设为 false,并将 env.localModelPath 指向服务器上存放模型文件的目录。默认情况下,ONNX Runtime 的 WASM 二进制文件也从 CDN 加载,因此还需将 env.backends.onnx.wasm.wasmPaths 指向你自己托管的副本。若要实现完全离线使用,再添加一个 service worker,使页面本身在无网络时也能加载。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.