Добавление субтитров к видео с помощью ИИ в браузере
Создавайте субтитры для видео в браузере с Transformers.js и Whisper. Извлекайте аудио, используйте WebGPU или WASM и формируйте WebVTT без отправки файлов.
Transformers.js запускает модель распознавания речи Whisper от OpenAI прямо в браузере, поэтому звуковую дорожку видео можно преобразовать в субтитры без загрузки файла на сервер, без API-ключа и вообще без бэкенда.
Большинство руководств по субтитрам начинаются с предложения отправить видео пользователя в API транскрибации и платить за каждую минуту. Если видео приватное или вам просто не нужны ещё один счёт и бэкенд, который придётся поддерживать, такой подход вам не подойдёт.
В этом руководстве весь конвейер строится на клиенте. Вы декодируете аудио, запускаете Whisper в Web Worker с переходом с WebGPU на WASM при необходимости, форматируете результат в корректный WebVTT и подключаете его к элементу <video>.
Ключевые выводы
- Whisper в transformers.js ожидает моноаудио с частотой дискретизации 16 кГц в виде
Float32Array, поэтому звуковую дорожку видео перед транскрибацией нужно декодировать и передискретизировать. - Наличие
navigator.gpuещё не означает, что WebGPU будет работать. Используйтеdevice: "webgpu", только еслиnavigator.gpu.requestAdapter()возвращает адаптер, а в остальных случаях переключайтесь на WASM. - Запускайте конвейер транскрибации в Web Worker. Иначе загрузка модели и инференс будут блокировать основной поток на всё время работы.
- Файл WebVTT начинается с
WEBVTT, реплики в нём разделяются пустыми строками, временные метки имеют форматhh:mm:ss.ttt, а символы&и<в тексте реплик необходимо экранировать. - Whisper в браузере практичен для роликов длиной в несколько минут. Для многочасовых записей или массовой обработки медиа используйте облачный сервис транскрибации.
Что мы создаём и почему всё остаётся на устройстве
Готовое браузерное приложение для создания субтитров состоит из четырёх частей: поля выбора файла, элемента <video>, воркера, в котором работает Whisper, и сгенерированного файла .vtt, подключённого как дорожка субтитров. Медиафайл считывается с диска пользователя в память и никогда не покидает вкладку. Единственный сетевой трафик — это однократная загрузка модели с Hugging Face Hub и, если вы не размещаете их у себя, WASM-файлов ONNX Runtime, которые transformers.js по умолчанию загружает с CDN. Ни аудио, ни видео никуда не отправляются.
В статье не рассматриваются WebCodecs, «вшитые» в изображение субтитры и экспорт в MP4. Субтитры остаются отдельной текстовой дорожкой, которую браузер отображает поверх видео. Покадровая обработка — задача для конвейера обработки видео в реальном времени на базе WebCodecs, а это совсем другая история.
Как извлечь звуковую дорожку из видео?
Whisper в transformers.js принимает моноаудио с частотой дискретизации 16 кГц в виде Float32Array. Конвейер принимает сырое аудио в виде типизированного массива и предполагает, что частота дискретизации уже правильная. Он её не проверяет, поэтому аудио с любой другой частотой даёт неверную транскрипцию без каких-либо ошибок.
Аудио из файла декодирует decodeAudioData(). Для видеофайлов результат зависит от того, какие контейнеры и кодеки поддерживает браузер, поэтому MP4, который нормально воспроизводится в одном браузере, может не декодироваться в другом. Затем декодированный буфер рендерится через OfflineAudioContext, созданный с одним каналом и частотой 16 000 Гц. Web Audio сводит исходный сигнал в один канал, чтобы он соответствовал одноканальному выходу, и передискретизирует его до частоты контекста, так что один рендеринг выполняет оба преобразования.
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 — позже он понадобится форматтеру.
Запуск Whisper из Transformers.js в Web Worker
При вызове ASR-конвейера transformers.js передайте return_timestamps: true. Тогда результат будет содержать массив chunks, каждый элемент которого включает строку text и пару timestamp: [start, end] в секундах. Запускайте конвейер внутри Web Worker, чтобы страница оставалась отзывчивой: иначе загрузка модели и инференс заблокируют основной поток на всё время работы.
Установите пакет командой npm install @huggingface/transformers. Воркер создаёт конвейер один раз и затем переиспользует его для каждого следующего файла:
// 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), а не копируя его. Конструкция new URL(..., import.meta.url) — это паттерн подключения воркеров, который распознаёт Vite; 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 конвейер нарезает длинное аудио на перекрывающиеся 30-секундные фрагменты и объединяет результаты в единый набор фрагментов с временными метками.
WebGPU или WASM: выбирайте устройство до загрузки модели
Transformers.js следует запускать Whisper на WebGPU, только если navigator.gpu.requestAdapter() возвращает адаптер, а в противном случае — на WASM. Для переключения на GPU достаточно одной опции конвейера, device: "webgpu", которая задаётся при загрузке модели. Однако наличие navigator.gpu не гарантирует, что WebGPU работает. Вызовите requestAdapter(), который может вернуть null, и выбирайте WebGPU, только если получен адаптер. navigator.gpu доступен и в воркерах, поэтому приведённая выше функция pickDevice() выполняется именно там.
| Условие | Устройство | Что получает пользователь |
|---|---|---|
navigator.gpu отсутствует | "wasm" | Более медленная транскрибация, только CPU |
navigator.gpu есть, но requestAdapter() возвращает null | "wasm" | Более медленная транскрибация, только CPU |
| Адаптер получен | "webgpu" | Инференс с ускорением на GPU |
Адаптер null — обычное дело на реальных машинах. В руководстве Chrome по устранению проблем с WebGPU перечислены типичные причины: пользователь отключил аппаратное ускорение графики в настройках, GPU находится в блок-листе Chrome, WebGPU ещё не поддерживается на данной платформе или Chrome вообще не может найти GPU. Самостоятельная проверка адаптера перед созданием конвейера, как в pickDevice(), покрывает все эти случаи.
Согласно руководству Hugging Face, глобальная поддержка WebGPU по состоянию на март 2026 года составляет около 85%. На странице статуса реализации WebGPU указано, что он включён по умолчанию в Chrome 113 и новее на Windows, macOS и ChromeOS, а также в Chrome 121 и новее на большинстве Android-устройств. В Linux Chrome включает его только для некоторых GPU. В Firefox он включён по умолчанию на Windows начиная с версии 141 и на Mac с Apple Silicon начиная с версии 147. Safari 26 поддерживает его на macOS, iOS, iPadOS и visionOS. В Firefox для Android он по-прежнему отключён по умолчанию: для работы нужен Firefox Beta или Nightly и настройка gfx.webgpu.ignore-blocklist в about:config.
Как преобразовать фрагменты Whisper в WebVTT?
Каждый фрагмент Whisper становится одной репликой (cue) WebVTT: время начала и окончания записывается в строку тайминга, а текст — в строку под ней. Правила описаны в справочнике MDN по WebVTT. Файл начинается с WEBVTT, реплики отделяются друг от друга пустой строкой, а две временные метки, соединённые -->, определяют, когда показывается каждая реплика.
Приведённый ниже форматтер всегда использует полную форму hh:mm:ss.ttt. Часы записываются двумя и более цифрами, минуты и секунды никогда не превышают 59, а миллисекунды всегда записываются тремя цифрами. Кроме того, каждая реплика должна заканчиваться позже, чем начинается. Два символа нельзя использовать в тексте реплики как есть: & и <. Код сначала заменяет & на &, а затем < на <. MDN также рекомендует записывать > как >, поэтому код делает и это.
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, "&").replace(/</g, "<").replace(/>/g, ">");
}
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.&экранируется первым, потому что остальные замены добавляют амперсанды, и если экранировать его последним, они будут экранированы дважды.- Схлопывание пробельных символов удаляет все переводы строк из текста модели. Пустая строка внутри содержимого реплики преждевременно завершает реплику.
- Запасной вариант
?? duration— мера предосторожности на случай, если последний фрагмент вернётся без времени окончания.
Вывод форматтера выглядит так (пример):
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 & nothing is uploaded.
Получив VTT-файл, вы также можете перевести субтитры на другие языки.
Подключение субтитров через Blob URL
Чтобы отобразить субтитры, оберните строку VTT в Blob с типом text/vtt, создайте для него object URL и укажите этот URL в качестве src элемента <track>. Задайте дорожке атрибуты 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 в браузере хорошо справляется с роликами длиной в несколько минут и плохо — с многочасовыми записями.
- Объём загрузки. При первом запуске загружаются веса модели размером от десятков до сотен мегабайт в зависимости от варианта. Ссылки на файлы есть на странице модели
whisper-tiny.en. Более крупные варианты Whisper на Hugging Face Hub точнее, но и тяжелее, поэтому перед переходом изучите карточку каждой модели. - Скорость. Длинные файлы обрабатываются медленно, особенно на WASM. Несколько минут аудио — нормально. Час аудио надолго займёт вкладку.
- Память. Всё декодированное аудио хранится в памяти в виде типизированного массива.
- Массовая обработка или длинные записи. Для многочасовых записей или пакетной обработки лучше подойдёт облачный сервис транскрибации.
Заключение
Короткое видео можно снабдить субтитрами полностью в браузере: декодировать и передискретизировать аудио, запустить Whisper в воркере на WebGPU (или на WASM, если адаптер недоступен), отформатировать фрагменты в WebVTT и подключить файл как дорожку на основе Blob. Начните с whisper-tiny.en на двухминутном ролике и сверьте тайминги реплик со звуком. Переходите на более крупную модель с Hub, только если точности недостаточно, а ваши пользователи готовы к более объёмной загрузке.
Часто задаваемые вопросы
Загружает ли transformers.js модель Whisper при каждой загрузке страницы?
Нет. При первом запуске transformers.js загружает файлы модели и сохраняет их в кэше браузера, поэтому последующие загрузки берут их из кэша, а не из сети. За это отвечает настройка env.useBrowserCache. В transformers.js v4 метод ModelRegistry.is_pipeline_cached сообщает, закэшированы ли уже файлы конвейера, а ModelRegistry.clear_pipeline_cache удаляет их.
Как показать прогресс загрузки модели, пока Whisper загружается в воркере?
Передайте функцию progress_callback в опциях конвейера рядом с device. Transformers.js вызывает её с обновлениями статуса по мере загрузки каждого файла модели. Внутри воркера пересылайте каждое обновление в основной поток через postMessage и отрисовывайте индикатор прогресса там. В transformers.js v4 появилось событие progress_total, которое сообщает общий прогресс загрузки, поэтому суммировать обновления по отдельным файлам самостоятельно не нужно.
Можно ли генерировать субтитры для видео не на английском языке?
Да, но для этого нужен мультиязычный чекпоинт Whisper вместо англоязычной модели с суффиксом .en. Передайте language и task при вызове транскрайбера, например language: 'french' и task: 'transcribe'. Если задать task: 'translate', Whisper будет выдавать английский текст из речи на другом языке. Устанавливайте srclang дорожки в соответствии с языком текста субтитров, а не языком аудио.
Может ли приложение для субтитров работать без обращения к Hugging Face Hub или CDN?
Да, если разместить все файлы у себя. Установите env.allowRemoteModels в false и укажите в env.localModelPath папку на вашем сервере с файлами модели. По умолчанию WASM-бинарники ONNX Runtime тоже загружаются с CDN, поэтому укажите в env.backends.onnx.wasm.wasmPaths пути к собственным копиям. Для полностью офлайн-работы добавьте service worker, чтобы и сама страница загружалась без подключения к сети.
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