Обработка видео с помощью WebCodecs

Манипулирование компонентами видеопотока.

Евгений Земцов
Eugene Zemtsov
Франсуа Бофор
François Beaufort

Современные веб-технологии предоставляют множество способов работы с видео. Media Stream API , Media Recording API , Media Source API и WebRTC API дополняют богатый набор инструментов для записи, передачи и воспроизведения видеопотоков. Решая определенные высокоуровневые задачи, эти API не позволяют веб-программистам работать с отдельными компонентами видеопотока, такими как кадры и несмешанные фрагменты кодированного видео или аудио. Чтобы получить низкоуровневый доступ к этим базовым компонентам, разработчики используют WebAssembly для добавления видео- и аудиокодеков в браузер. Но учитывая, что современные браузеры уже поставляются с различными кодеками (которые часто ускоряются аппаратно), переупаковка их в WebAssembly кажется пустой тратой человеческих и компьютерных ресурсов.

API WebCodecs устраняет эту неэффективность, предоставляя программистам возможность использовать компоненты мультимедиа, которые уже присутствуют в браузере. В частности:

  • Видео и аудио декодеры
  • Видео и аудио кодеры
  • Необработанные видеокадры
  • Декодеры изображений

API WebCodecs полезен для веб-приложений, которым требуется полный контроль над обработкой медиаконтента, например, для видеоредакторов, видеоконференций, потокового видео и т. д.

Рабочий процесс обработки видео

Кадры являются центральным элементом в обработке видео. Таким образом, в WebCodecs большинство классов либо потребляют, либо производят кадры. Видеокодеры преобразуют кадры в закодированные фрагменты. Видеодекодеры делают обратное.

Также VideoFrame прекрасно работает с другими веб-API, будучи CanvasImageSource и имея конструктор , который принимает CanvasImageSource . Поэтому его можно использовать в таких функциях, как drawImage() и texImage2D() . Также его можно создавать из холстов, растровых изображений, видеоэлементов и других видеокадров.

API WebCodecs хорошо работает в тандеме с классами из API вставляемых потоков , которые подключают WebCodecs к дорожкам медиапотока .

  • MediaStreamTrackProcessor разбивает медиадорожки на отдельные кадры.
  • MediaStreamTrackGenerator создает медиа-дорожку из потока кадров.

Веб-кодеки и веб-работники

По замыслу API WebCodecs выполняет всю тяжелую работу асинхронно и вне основного потока. Но поскольку обратные вызовы кадров и фрагментов часто могут вызываться несколько раз в секунду, они могут загромождать основной поток и, таким образом, делать веб-сайт менее отзывчивым. Поэтому предпочтительнее перенести обработку отдельных кадров и закодированных фрагментов в веб-воркер.

Чтобы помочь с этим, ReadableStream предоставляет удобный способ автоматической передачи всех кадров, поступающих с медиа-дорожки, в worker. Например, MediaStreamTrackProcessor можно использовать для получения ReadableStream для медиа-дорожки, поступающей с веб-камеры. После этого поток передается в web worker, где кадры считываются один за другим и ставятся в очередь в VideoEncoder .

С HTMLCanvasElement.transferControlToOffscreen даже рендеринг может быть выполнен вне основного потока. Но если все высокоуровневые инструменты оказались неудобными, сам VideoFrame является переносимым и может перемещаться между работниками.

Веб-кодеки в действии

Кодирование

Путь от Canvas или ImageBitmap к сети или хранилищу
Путь от Canvas или ImageBitmap к сети или хранилищу

Все начинается с VideoFrame . Существует три способа создания видеокадров.

  • Из источника изображения, например холста, растрового изображения или видеоэлемента.

    const canvas = document.createElement("canvas"); // Draw something on the canvas...  const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 }); 
  • Используйте MediaStreamTrackProcessor для извлечения кадров из MediaStreamTrack

    const stream = await navigator.mediaDevices.getUserMedia({}); const track = stream.getTracks()[0];  const trackProcessor = new MediaStreamTrackProcessor(track);  const reader = trackProcessor.readable.getReader(); while (true) {   const result = await reader.read();   if (result.done) break;   const frameFromCamera = result.value; } 
  • Создать кадр из его двоичного пиксельного представления в BufferSource

    const pixelSize = 4; const init = {   timestamp: 0,   codedWidth: 320,   codedHeight: 200,   format: "RGBA", }; const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize); for (let x = 0; x < init.codedWidth; x++) {   for (let y = 0; y < init.codedHeight; y++) {     const offset = (y * init.codedWidth + x) * pixelSize;     data[offset] = 0x7f;      // Red     data[offset + 1] = 0xff;  // Green     data[offset + 2] = 0xd4;  // Blue     data[offset + 3] = 0x0ff; // Alpha   } } const frame = new VideoFrame(data, init); 

Независимо от источника кадры можно кодировать в объекты EncodedVideoChunk с помощью VideoEncoder .

Перед кодированием VideoEncoder необходимо предоставить два объекта JavaScript:

  • Словарь Init с двумя функциями для обработки закодированных фрагментов и ошибок. Эти функции определяются разработчиком и не могут быть изменены после передачи в конструктор VideoEncoder .
  • Объект конфигурации кодировщика, содержащий параметры для выходного видеопотока. Вы можете изменить эти параметры позже, вызвав configure() .

Метод configure() выдаст NotSupportedError , если конфигурация не поддерживается браузером. Вам рекомендуется вызвать статический метод VideoEncoder.isConfigSupported() с конфигурацией, чтобы заранее проверить, поддерживается ли конфигурация, и дождаться ее обещания.

const init = {   output: handleChunk,   error: (e) => {     console.log(e.message);   }, };  const config = {   codec: "vp8",   width: 640,   height: 480,   bitrate: 2_000_000, // 2 Mbps   framerate: 30, };  const { supported } = await VideoEncoder.isConfigSupported(config); if (supported) {   const encoder = new VideoEncoder(init);   encoder.configure(config); } else {   // Try another config. } 

После настройки кодировщика он готов принимать кадры через метод encode() . Оба configure() и encode() возвращаются немедленно, не дожидаясь завершения фактической работы. Он позволяет нескольким кадрам стоять в очереди на кодирование одновременно, в то время как encodeQueueSize показывает, сколько запросов ожидают в очереди завершения предыдущих кодирований. Ошибки сообщаются либо путем немедленного создания исключения, в случае если аргументы или порядок вызовов методов нарушают контракт API, либо путем вызова обратного вызова error() для проблем, возникших в реализации кодека. Если кодирование завершается успешно, обратный вызов output() вызывается с новым закодированным фрагментом в качестве аргумента. Еще одна важная деталь здесь заключается в том, что кадрам необходимо сообщать, когда они больше не нужны, с помощью вызова close() .

let frameCounter = 0;  const track = stream.getVideoTracks()[0]; const trackProcessor = new MediaStreamTrackProcessor(track);  const reader = trackProcessor.readable.getReader(); while (true) {   const result = await reader.read();   if (result.done) break;    const frame = result.value;   if (encoder.encodeQueueSize > 2) {     // Too many frames in flight, encoder is overwhelmed     // let's drop this frame.     frame.close();   } else {     frameCounter++;     const keyFrame = frameCounter % 150 == 0;     encoder.encode(frame, { keyFrame });     frame.close();   } } 

Наконец, пришло время завершить кодирование кода, написав функцию, которая обрабатывает фрагменты закодированного видео, когда они выходят из кодировщика. Обычно эта функция отправляет фрагменты данных по сети или объединяет их в медиаконтейнер для хранения.

function handleChunk(chunk, metadata) {   if (metadata.decoderConfig) {     // Decoder needs to be configured (or reconfigured) with new parameters     // when metadata has a new decoderConfig.     // Usually it happens in the beginning or when the encoder has a new     // codec specific binary configuration. (VideoDecoderConfig.description).     fetch("/upload_extra_data", {       method: "POST",       headers: { "Content-Type": "application/octet-stream" },       body: metadata.decoderConfig.description,     });   }    // actual bytes of encoded data   const chunkData = new Uint8Array(chunk.byteLength);   chunk.copyTo(chunkData);    fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {     method: "POST",     headers: { "Content-Type": "application/octet-stream" },     body: chunkData,   }); } 

Если в какой-то момент вам понадобится убедиться, что все ожидающие запросы на кодирование завершены, вы можете вызвать flush() и дождаться его обещания.

await encoder.flush(); 

Расшифровка

Путь от сети или хранилища к Canvas или ImageBitmap.
Путь от сети или хранилища к Canvas или ImageBitmap .

Настройка VideoDecoder аналогична настройке VideoEncoder : при создании декодера передаются две функции, а параметры кодека передаются в configure() .

Набор параметров кодека варьируется от кодека к кодеку. Например, кодеку H.264 может потребоваться двоичный блок AVCC, если только он не закодирован в так называемом формате Annex B ( encoderConfig.avc = { format: "annexb" } ).

const init = {   output: handleFrame,   error: (e) => {     console.log(e.message);   }, };  const config = {   codec: "vp8",   codedWidth: 640,   codedHeight: 480, };  const { supported } = await VideoDecoder.isConfigSupported(config); if (supported) {   const decoder = new VideoDecoder(init);   decoder.configure(config); } else {   // Try another config. } 

После инициализации декодера вы можете начать кормить его объектами EncodedVideoChunk . Чтобы создать фрагмент, вам понадобится:

  • BufferSource закодированных видеоданных
  • начальная временная метка фрагмента в микросекундах (время носителя первого закодированного кадра в фрагменте)
  • тип фрагмента, один из:
    • key , если фрагмент может быть декодирован независимо от предыдущих фрагментов
    • delta , если фрагмент может быть декодирован только после того, как был декодирован один или несколько предыдущих фрагментов

Также любые фрагменты, выдаваемые кодером, готовы для декодера как есть. Все, что было сказано выше об ошибках и асинхронной природе методов кодера, в равной степени справедливо и для декодеров.

const responses = await downloadVideoChunksFromServer(timestamp); for (let i = 0; i < responses.length; i++) {   const chunk = new EncodedVideoChunk({     timestamp: responses[i].timestamp,     type: responses[i].key ? "key" : "delta",     data: new Uint8Array(responses[i].body),   });   decoder.decode(chunk); } await decoder.flush(); 

Теперь пришло время показать, как можно отобразить на странице свежедекодированный кадр. Лучше убедиться, что обратный вызов вывода декодера ( handleFrame() ) быстро возвращается. В примере ниже он только добавляет кадр в очередь кадров, готовых к рендерингу. Рендеринг происходит отдельно и состоит из двух шагов:

  1. Жду подходящего момента, чтобы показать рамку.
  2. Рисуем рамку на холсте.

Как только фрейм больше не нужен, вызовите close() , чтобы освободить базовую память до того, как до нее доберется сборщик мусора. Это уменьшит средний объем памяти, используемой веб-приложением.

const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); let pendingFrames = []; let underflow = true; let baseTime = 0;  function handleFrame(frame) {   pendingFrames.push(frame);   if (underflow) setTimeout(renderFrame, 0); }  function calculateTimeUntilNextFrame(timestamp) {   if (baseTime == 0) baseTime = performance.now();   let mediaTime = performance.now() - baseTime;   return Math.max(0, timestamp / 1000 - mediaTime); }  async function renderFrame() {   underflow = pendingFrames.length == 0;   if (underflow) return;    const frame = pendingFrames.shift();    // Based on the frame's timestamp calculate how much of real time waiting   // is needed before showing the next frame.   const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);   await new Promise((r) => {     setTimeout(r, timeUntilNextFrame);   });   ctx.drawImage(frame, 0, 0);   frame.close();    // Immediately schedule rendering of the next frame   setTimeout(renderFrame, 0); } 

Советы разработчикам

Используйте панель «Медиа» в Chrome DevTools для просмотра журналов мультимедиа и отладки веб-кодеков.

Скриншот панели мультимедиа для отладки веб-кодеков
Панель мультимедиа в Chrome DevTools для отладки веб-кодеков.

Демо

Демонстрация ниже показывает, как выглядят кадры анимации на холсте:

  • захвачено со скоростью 25 кадров в секунду в ReadableStream с помощью MediaStreamTrackProcessor
  • переведен в веб-работник
  • закодировано в видеоформат H.264
  • декодируется снова в последовательность видеокадров
  • и визуализируется на втором холсте с помощью transferControlToOffscreen()

Другие демо

Также посмотрите наши другие демонстрации:

Использование API WebCodecs

Обнаружение особенностей

Чтобы проверить поддержку WebCodecs:

if ('VideoEncoder' in window) {   // WebCodecs API is supported. } 

Помните, что API WebCodecs доступен только в безопасных контекстах , поэтому обнаружение не будет выполнено, если self.isSecureContext имеет значение false.

Обратная связь

Команда Chrome хочет узнать о вашем опыте работы с API WebCodecs.

Расскажите нам о дизайне API

Есть ли что-то в API, что работает не так, как вы ожидали? Или отсутствуют методы или свойства, которые вам нужны для реализации вашей идеи? У вас есть вопрос или комментарий по модели безопасности? Отправьте запрос спецификации в соответствующий репозиторий GitHub или добавьте свои мысли к существующему запросу.

Сообщить о проблеме с реализацией

Вы нашли ошибку в реализации Chrome? Или реализация отличается от спецификации? Сообщите об ошибке на new.crbug.com . Обязательно включите как можно больше подробностей, простые инструкции по воспроизведению и введите Blink>Media>WebCodecs в поле Components .

Показать поддержку API

Планируете ли вы использовать API WebCodecs? Ваша публичная поддержка помогает команде Chrome расставлять приоритеты функций и показывает другим поставщикам браузеров, насколько важно их поддерживать.

Отправьте электронное письмо на адрес [email protected] или отправьте твит @ChromiumDev с хэштегом #WebCodecs и расскажите нам, где и как вы его используете.

Главное изображение от Дениз Янс на Unsplash .