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

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

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

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

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

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

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

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

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

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

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

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

Веб-кодеки и веб-воркеры

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

Чтобы помочь в этом, ReadableStream предоставляет удобный способ автоматической передачи всех кадров, поступающих с медиа-трека, в работника. Например, MediaStreamTrackProcessor можно использовать для получения ReadableStream для дорожки медиапотока, поступающего с веб-камеры. После этого поток передается веб-воркеру, где кадры считываются один за другим и ставятся в очередь в 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:

  • Словарь инициализации с двумя функциями для обработки закодированных фрагментов и ошибок. Эти функции определяются разработчиком и не могут быть изменены после передачи конструктору 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, если только он не закодирован в так называемом формате Приложения 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 для просмотра журналов мультимедиа и отладки веб-кодеков.

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

Демо

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

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

Другие демо

Также ознакомьтесь с другими нашими демо-версиями:

Использование API веб-кодеков

Обнаружение функций

Чтобы проверить поддержку 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 в поле «Компоненты» . Glitch отлично подходит для быстрого и простого обмена репродукциями.

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

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

Отправьте электронное письмо на адрес media-dev@chromium.org или отправьте твит на адрес @ChromiumDev, используя хэштег #WebCodecs , и сообщите нам, где и как вы его используете.

Изображение героя , созданное Дениз Янс на Unsplash .