Обработка видео в реальном времени с помощью WebCodecs API
Обработка видео WebCodecs с MediaStreamTrackProcessor, TransformStream и VideoTrackGenerator, плюс close кадров, backpressure, worker и поддержка браузеров.
Конвейер обработки видео на основе WebCodecs состоит из трёх частей: MediaStreamTrackProcessor, преобразующий MediaStreamTrack в ReadableStream<VideoFrame>; TransformStream, в котором выполняется обработка каждого кадра; и VideoTrackGenerator, конвертирующий обработанные кадры обратно в MediaStreamTrack, который можно назначить элементу <video>. VideoTrackGenerator — это актуальное название согласно спецификации; в примерах для Chromium по-прежнему используется устаревший нестандартный MediaStreamTrackGenerator. Ниже представлен полный конвейер:
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const processor = new MediaStreamTrackProcessor({ track });
const generator = new VideoTrackGenerator();
const grayscale = new TransformStream({
async transform(frame, controller) {
try {
const canvas = new OffscreenCanvas(frame.displayWidth, frame.displayHeight);
const ctx = canvas.getContext('2d');
ctx.filter = 'grayscale(1)';
ctx.drawImage(frame, 0, 0);
controller.enqueue(new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration,
}));
} finally {
frame.close();
}
},
});
processor.readable.pipeThrough(grayscale).pipeTo(generator.writable);
videoEl.srcObject = new MediaStream([generator.track]);
Эта статья посвящена тому, что обходят стороной все существующие руководства: сценариям отказов. Приведённый выше «счастливый путь» работает ровно до тех пор, пока что-то не идёт не так — пока кадры не начинают утекать, трансформация не отстаёт от потока, энкодер не переходит в закрытое состояние, или вы не выпускаете код с предположением о том, что Safari его не поддерживает. Каждый из этих случаев — реальный производственный сбой с конкретной причиной и конкретным решением, и именно об этом пойдёт речь далее.
Ключевые выводы
- Конвейер WebCodecs строится по схеме
MediaStreamTrackProcessor→TransformStream→VideoTrackGenerator; используйте конструктор согласно спецификации —new MediaStreamTrackProcessor({ track }), а не устаревшую позиционную форму. - Отсутствие вызова
frame.close()исчерпывает конечные медиаресурсы, от которых зависит конвейер; после их исчерпания эмиссия кадров останавливается, что приводит к характерному симптому: видео начинает заикаться, а затем полностью зависает, тогда как остальная часть страницы остаётся отзывчивой. MediaStreamTrackProcessorне передаёт обратное давление (backpressure) вышестоящим компонентам — когда трансформация отстаёт, процессор молча отбрасывает самые старые кадры, не генерируя никаких ошибок.- Выполняйте всю работу с
VideoFrameв одном воркере: кадр, переданный через границы воркеров, автоматически закрывается на стороне отправителя, и любая последующая попытка обратиться к нему вызовет исключение. - Поддержка WebCodecs различается в зависимости от интерфейса — базовые
VideoEncoder/VideoFrameдоступны начиная с Chrome 94+, Firefox 130+ и Safari 16.4+, тогда какMediaStreamTrackProcessor/VideoTrackGeneratorпоявились позже (Safari 18+, в Firefox не поддерживаются).
Что такое WebCodecs и почему конвейер выглядит именно так
WebCodecs предоставляет JavaScript прямой доступ к встроенным медиакодекам браузера, нередко использующим аппаратное ускорение, а также к необработанным видеокадрам. До его появления MediaStream был непрозрачным: вы передавали его в элемент <video>, и браузер самостоятельно управлял всем процессом от захвата до отображения. WebCodecs открывает этот конвейер. Интерфейс VideoFrame предоставляет доступ к необработанным пикселям между захватом и кодированием — именно там должны располагаться фильтр, виртуальный фон или пользовательский энкодер.
Конвейер построен на основе Streams по той причине, что необработанные декодированные кадры занимают много места (несколько мегабайт каждый) и поступают с высокой частотой (от 25 кадров в секунду и выше), поэтому необходимы управление потоком и инкрементальная обработка, а не буферизация всего в памяти. WHATWG Streams API был разработан именно для такой атомарной обработки фрагментов данных в цепочках каналов. MediaStreamTrackProcessor соединяет живой трек со стримом; TransformStream — это место, где выполняется обработка каждого кадра; VideoTrackGenerator возвращает результат в трек, понятный остальной платформе — <video>, RTCPeerConnection.
WebCodecs работает исключительно с потоками без контейнеров. Если вам нужно читать или записывать MP4/ISOBMFF, логику контейнера придётся реализовать самостоятельно. Для аудио существует аналогичный интерфейс (AudioData, AudioEncoder), который в данной статье не рассматривается; все описанные ниже паттерны относятся исключительно к видео.
Рабочий конвейер: камера → фильтр → отображение
Discover how at OpenReplay.com.
Рабочий конвейер фильтрации на WebCodecs захватывает видео с помощью MediaStreamTrackProcessor, применяет фильтр внутри TransformStream, используя Canvas2D непосредственно на VideoFrame, и выводит результат через VideoTrackGenerator — именно такая схема показана в открывающем блоке кода. Ключевой приём для повышения эффективности — ctx.drawImage(frame, 0, 0): метод drawImage принимает VideoFrame напрямую в качестве источника, что позволяет отрисовывать кадры на canvas без ручного преобразования в PNG или создания промежуточного ImageBitmap.
Для цветового фильтра на Canvas2D строка ctx.filter является наименее затратным способом. Для задач, требующих доступа к отдельным пикселям — хромакей, пользовательская свёртка — используйте getImageData/putImageData:
const filter = new TransformStream({
async transform(frame, controller) {
try {
const w = frame.displayWidth, h = frame.displayHeight;
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(frame, 0, 0);
const imageData = ctx.getImageData(0, 0, w, h);
const px = imageData.data;
for (let i = 0; i < px.length; i += 4) {
const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
px[i] = px[i + 1] = px[i + 2] = lum;
}
ctx.putImageData(imageData, 0, 0);
controller.enqueue(new VideoFrame(canvas, {
timestamp: frame.timestamp,
duration: frame.duration,
}));
} finally {
frame.close();
}
},
});
Из исходного кадра в новый переносятся два параметра: timestamp и duration. timestamp — это идентификатор кадра на протяжении всего конвейера: он сохраняется через циклы кодирования/декодирования и используется для последующего измерения задержки. Потеря этого значения лишает нижестоящих потребителей возможности определять порядок кадров.
Для интенсивной попиксельной обработки при полном разрешении узким местом становится чтение данных через getImageData; WebGL или WebGPU (через importExternalTexture) сохраняют кадр на GPU и полностью исключают обратную передачу данных на CPU. Используйте Canvas2D для цветовых преобразований и простого композитинга; переходите к GPU-пути, когда попиксельные вычисления занимают основную часть бюджета кадра.
Жизненный цикл VideoFrame: почему frame.close() обязателен
Отсутствие вызова frame.close() — это не просто обычная утечка памяти: оно исчерпывает конечные медиаресурсы, от которых зависит конвейер. После их исчерпания декодирование или эмиссия кадров останавливается, поскольку новый кадр не может быть выделен или отправлен. Это проявляется характерным симптомом: видео начинает постепенно заикаться, а затем полностью зависает, тогда как остальная часть страницы остаётся отзывчивой. VideoFrame.close() освобождает базовый медиаресурс, удерживаемый кадром, и спецификация WebCodecs явно указывает на конечность этих ресурсов — кадры, подкреплённые аппаратными буферами, берутся из ограниченного пула, и источник не может эмитировать новый кадр, когда пул заполнен.
Именно поэтому close() — это не опциональная очистка, которую можно отложить до сборки мусора. Сборщик мусора не осведомлён о базовом медиаресурсе и работает по собственному расписанию; к моменту его запуска пул уже будет исчерпан. Каждый VideoFrame, считанный из процессора, и каждый созданный вами кадр должны быть закрыты ровно один раз после завершения работы с ними.
Неочевидный сценарий отказа — путь ошибки. Если трансформация выбрасывает исключение после чтения кадра, но до его закрытия, этот кадр утекает. А трансформация, выбрасывающая исключение на одном кадре, как правило, делает то же самое на следующем — и утечка быстро накапливается. Решение — try/finally:
async transform(frame, controller) {
try {
// ...работа с фильтром, которая может выбросить исключение...
controller.enqueue(newFrame);
} finally {
frame.close(); // выполняется независимо от того, было ли выброшено исключение
}
}
finally гарантирует выполнение frame.close() как при успешном завершении, так и при возникновении ошибки. Это наиболее важный паттерн в конвейере WebCodecs.
Обратное давление: почему медленные трансформации молча отбрасывают кадры
MediaStreamTrackProcessor не передаёт обратное давление (backpressure) вышестоящим компонентам. Когда ваш TransformStream отстаёт, процессор молча отбрасывает самые старые кадры, не замедляя камеру, и вы никогда не увидите ошибки — только пропущенные кадры. Практическое следствие: трансформация, выполняющаяся 50 мс на кадр при источнике 30 fps (бюджет 33 мс), не вызовет ошибки и не будет накапливать очередь бесконечно. Она будет молча работать примерно на 20 fps, а разница будет отбрасываться. Обнаружить это можно, отслеживая очередь на стороне readable изнутри трансформации. TransformStreamDefaultController.desiredSize отражает состояние обратного давления на стороне readable — когда значение становится отрицательным, readable-сторона превысила отметку высокого уровня воды, и потребитель отстаёт:
const filter = new TransformStream({
async transform(frame, controller) {
try {
if (controller.desiredSize !== null && controller.desiredSize < 0) {
// Потребитель отстаёт. Намеренно отбрасываем этот кадр,
// чтобы не отставать ещё больше.
return;
}
// ...работа с фильтром...
controller.enqueue(newFrame);
} finally {
frame.close();
}
},
});
При обнаружении обратного давления у вас есть два инструмента. Первый — намеренное отбрасывание кадров: пропустите текущий кадр, как показано выше, заменив случайные потери предсказуемым ритмом. Второй — снижение входной нагрузки: запросите меньшее разрешение или частоту кадров через getUserMedia с помощью MediaTrackConstraints или вызовите track.applyConstraints() для динамического снижения параметров во время выполнения. Уменьшение разрешения напрямую сокращает объём попиксельной работы на кадр и, как правило, является наиболее эффективным решением для фильтра, ограниченного производительностью CPU.
Воркеры: почему всю работу с VideoFrame нужно выполнять в одном воркере?
Выполняйте всю работу с VideoFrame в одном воркере. При передаче VideoFrame через границы воркеров с помощью postMessage ссылка на стороне отправителя автоматически закрывается, и любая попытка повторно прочитать или закрыть её вызовет исключение — это незаметное состояние гонки, которое практически невозможно отладить в очередях сообщений между воркерами. Кадры внутри переданных стримов сериализуются, что клонирует их и требует явного закрытия на обеих сторонах. Смешение этих подходов приводит к ошибке преждевременного закрытия:
controller.enqueue(frame);
frame.close(); // Слишком рано — enqueue асинхронен; кадр может ещё быть в процессе передачи
Поскольку controller.enqueue() является асинхронным по отношению к принимающему воркеру, преждевременное закрытие ссылки отправителя вызывает ошибки сериализации, тогда как отсутствие закрытия приводит к описанной выше утечке с последующим зависанием. Держите всю цепочку MediaStreamTrackProcessor → TransformStream → VideoTrackGenerator в одном воркере — и проблема владения исчезнет. (Для передачи закодированных фрагментов с устройства — через WebTransport или data channels — обратитесь к серии статей о конвейерах на webrtcHacks; это отдельная тема.)
Если вам всё же нужно передать кадр в воркер — для питания конвейера, а не для его разделения — передавайте его явно и прекращайте работу с ним на стороне отправителя:
// Основной поток
worker.postMessage({ frame }, { transfer: [frame] });
// `frame` теперь аннулирован здесь. Не читайте и не закрывайте его в основном потоке.
После передачи принимающий воркер становится владельцем кадра и несёт ответственность за его закрытие. Передающий поток должен считать свою ссылку недействительной.
Кодирование для передачи или записи
VideoEncoder сжимает необработанные объекты VideoFrame в объекты EncodedVideoChunk, доставляемые через callback вывода для записи или передачи. Настройте его с помощью строки кодека, размеров, битрейта и частоты кадров:
const chunks = [];
const encoder = new VideoEncoder({
output: (chunk, metadata) => {
// chunk.type — 'key' или 'delta'; chunk содержит timestamp, duration, byteLength
chunks.push(chunk);
},
error: (e) => console.error('encoder error', e),
});
encoder.configure({
codec: 'vp8', // или, например, 'avc1.42001f' для H.264 baseline
width: 640,
height: 480,
bitrate: 1_000_000,
framerate: 30,
});
Callback output предоставляет EncodedVideoChunk и опциональные метаданные; фрагмент содержит type ('key' или 'delta'), timestamp, duration и закодированные байты. Для строк кодеков обращайтесь к реестру кодеков WebCodecs и руководству MDN по кодекам, а не пытайтесь угадать строки профилей AVC.
Запросите ключевой кадр с помощью encoder.encode(frame, { keyFrame: true }) (обратите внимание на заглавную F), когда вам нужен интра-кадр — например, в начале потока, после перемотки или в точке восстановления. Кодирование каждого кадра как ключевого полностью нивелирует межкадровое сжатие и значительно увеличит битрейт. Написание этой опции задокументировано в руководстве MDN Using the WebCodecs API.
Восстановление после закрытия энкодера
Когда callback ошибки VideoEncoder срабатывает и энкодер переходит в состояние 'closed', он не может быть повторно использован. VideoEncoder.reset() предназначен для нетерминальных случаев, но восстановление после закрытого энкодера требует создания нового экземпляра и повторного вызова configure() с теми же параметрами. Проверяйте состояние перед каждым вызовом encode() и пересоздавайте энкодер при закрытии:
function encodeFrame(frame, keyFrame = false) {
if (encoder.state === 'closed') {
encoder = makeEncoder(); // создаём и настраиваем новый VideoEncoder
}
if (encoder.state === 'configured') {
encoder.encode(frame, { keyFrame });
}
}
Защита encode() проверкой состояния и путём пересоздания — это то, что обеспечивает работоспособность долгосрочной сессии при временных ошибках кодека.
Поддержка браузерами в 2026 году
Поддержка WebCodecs различается в зависимости от интерфейса, и рассматривать её как единый номер версии — типичная ошибка всех устаревших руководств. Базовые интерфейсы VideoEncoder/VideoFrame широко доступны; компоненты Insertable Streams — MediaStreamTrackProcessor и VideoTrackGenerator — появились позже и по иному графику.
| Интерфейс | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
VideoEncoder / VideoFrame (базовый WebCodecs) | 94+ | 130+ | 16.4+ |
MediaStreamTrackProcessor | 94+ | Не поддерживается | 18+ |
VideoTrackGenerator | Не поддерживается | Не поддерживается | 18+ |
MediaStreamTrackGenerator (нестандартный) | 94+ | Не поддерживается | Не поддерживается |
Данные проверены по таблицам совместимости MDN для VideoEncoder и MediaStreamTrackProcessor. Расхожее утверждение из большинства руководств о том, что «Safari не поддерживает WebCodecs», одновременно устарело и неточно: Safari поддерживает базовый WebCodecs начиная с версии 16.4, а в Safari 17.4 была расширена поддержка кодеков, включая HEVC. Чего не хватает Safari и Firefox — так это слоя захвата/вывода Insertable Streams. Таким образом, описанный выше конвейер «камера → фильтр → отображение» сегодня работает в Chromium, а в Safari 18+ — при реализации в выделенном воркере. В Firefox же можно кодировать и декодировать кадры, получая их другим способом.
Практический вывод: определяйте наличие функций по каждому интерфейсу отдельно, а не по браузеру в целом. Проверяйте window.MediaStreamTrackProcessor и window.VideoEncoder независимо друг от друга и предусматривайте запасной вариант на основе Canvas/requestVideoFrameCallback для слоя захвата там, где компоненты Insertable Streams отсутствуют.
Контрольный список для отладки
Три сценария отказов в конвейере WebCodecs — потеря кадров, неконтролируемый рост памяти и всплески задержки — каждый имеет характерный симптом и конкретный диагностический шаг.
| Симптом | Вероятная причина | Диагностический шаг |
|---|---|---|
| Нарастающее заикание с последующим зависанием, остальная часть страницы отзывчива | Отсутствие frame.close() на каком-либо пути → исчерпание конечных медиаресурсов | Проверьте каждый считанный или созданный VideoFrame на наличие ровно одного вызова close(); убедитесь в наличии блока try/finally |
| Пропущенные кадры, нет ошибок в консоли | Медленная трансформация; процессор молча отбрасывает самые старые кадры | Логируйте controller.desiredSize внутри трансформации; если значение уходит в отрицательную область, потребитель отстаёт |
| Задержка нарастает со временем | Медленный попиксельный фильтр, поглощающий бюджет кадра | Измерьте продолжительность каждого шага; сравните с бюджетом для вашей частоты кадров (33 мс при 30 fps) |
| Энкодер перестаёт выдавать фрагменты | VideoEncoder перешёл в состояние 'closed' после ошибки | Проверяйте encoder.state перед каждым вызовом encode(); пересоздавайте энкодер при состоянии 'closed' |
Симптом «заикание с последующим зависанием» стоит научиться распознавать сразу. Записи сессий для функций на основе WebCodecs надёжно воспроизводят этот паттерн: плавное видео, которое сначала начинает заметно терять кадры, а затем полностью зависает, тогда как остальной интерфейс остаётся интерактивным. Это визуальная сигнатура исчерпания конечных медиаресурсов из-за незакрытых кадров — запись сессии чётко показывает симптом, но причина остаётся невидимой, если не знать, что нужно искать незакрытый кадр где-то в коде конвейера.
Для измерения реальной сквозной задержки — от захвата камерой до отображения — закодируйте timestamp кадра в виде пиксельного оверлея перед конвейером и декодируйте его из отрендеренного вывода с помощью requestVideoFrameCallback. В качестве ориентира, бенчмарк конвейера webrtcHacks (март 2023 года) зафиксировал следующие затраты времени на кадр:
| Шаг | Продолжительность |
|---|---|
| Удаление фона | 22 мс |
| Добавление оверлея | 1 мс |
| Кодирование | 8 мс |
| Декодирование | 1 мс |
| Отображение | 38 мс |
Ваши показатели будут варьироваться в зависимости от аппаратного обеспечения и сложности фильтра. Примечательный результат: отображение само по себе занимает ~38 мс — это доминирующий член, — а значит, фильтр, легко укладывающийся в бюджет 30 fps, всё равно может ощущаться как лагающий, если не учитывать «хвост» отображения. Измеряйте весь путь, а не только свою трансформацию.
Заключение
Схема конвейера WebCodecs — MediaStreamTrackProcessor → TransformStream → VideoTrackGenerator — достаточно компактна, чтобы уместиться в одном блоке кода, однако разрыв между демонстрацией и готовым к выпуску решением целиком определяется сценариями отказов: закрытием каждого кадра, обнаружением незаметного обратного давления, удержанием всей цепочки в одном воркере, восстановлением после закрытого энкодера и определением наличия функций по каждому интерфейсу отдельно, а не по браузеру в целом. Начните с примера try/finally из начала статьи, добавьте проверку desiredSize и защиту состояния энкодера — и вы получите конвейер, способный пережить случаи, до которых руководства по «счастливому пути» никогда не добираются.
Часто задаваемые вопросы
Когда использовать Canvas2D, а когда WebGL или WebGPU для фильтра WebCodecs?
Используйте Canvas2D для цветовых преобразований и простого композитинга, где строки ctx.filter или умеренные циклы getImageData укладываются в бюджет кадра. Переходите к WebGL или WebGPU, когда попиксельные вычисления занимают основную часть бюджета: эти API сохраняют кадр на GPU через importExternalTexture и исключают обратную передачу данных на CPU, которую вынуждает getImageData. При полном разрешении именно эта обратная передача обычно является узким местом, поэтому GPU-путь — это решение для интенсивной попиксельной работы, например хромакея.
Почему мои VideoFrame неожиданно закрываются при передаче между воркерами?
Передача VideoFrame через границу воркера с помощью postMessage автоматически закрывает ссылку на стороне отправителя, поэтому любая попытка прочитать или закрыть её снова на стороне отправителя вызовет исключение. Это отличается от кадров внутри переданных стримов, которые сериализуются и клонируются и требуют явного закрытия на обеих сторонах. Чтобы избежать состояния гонки, держите весь конвейер в одном воркере, или после передачи считайте ссылку отправителя недействительной и позвольте принимающему воркеру владеть кадром и закрывать его.
Работает ли конвейер «камера → фильтр → отображение» в Firefox?
Не полностью. Firefox 130 и более поздние версии поддерживают базовые интерфейсы VideoEncoder и VideoFrame, однако слой захвата и вывода Insertable Streams не поддерживается — это означает, что MediaStreamTrackProcessor и VideoTrackGenerator недоступны. Кодировать и декодировать кадры в Firefox можно, но источником кадров должен служить другой механизм, например Canvas с requestVideoFrameCallback. Определяйте наличие функций по каждому интерфейсу отдельно, проверяя window.MediaStreamTrackProcessor и window.VideoEncoder независимо, а не тестируя браузер в целом.
В чём разница между VideoEncoder.reset() и пересозданием энкодера?
VideoEncoder.reset() предназначен для нетерминальных случаев: он очищает ожидающую обработку работу на энкодере, который всё ещё пригоден к использованию. Он не может восстановить энкодер, перешедший в состояние closed после срабатывания ошибки, поскольку закрытый энкодер не может быть перенастроен или повторно использован. Восстановление после closed означает создание нового экземпляра VideoEncoder и повторный вызов configure() с теми же параметрами. Проверяйте encoder.state перед каждым вызовом encode() и пересоздавайте энкодер, когда значение равно closed.
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