Как эффективно управлять видеопотоком с веб-камеры в браузере

b7e0f93f900841a4518a0c7c95b704aa.jpg

Веб‑технологии, такие как Media Capture and Streams API (или просто MediaStream API), открывают большие возможности для работы с видеопотоком в браузере. Они позволяют легко захватывать видеопоток с веб‑камеры и использовать его для создания мощных и интерактивных веб‑приложений. Однако несмотря на широкую доступность этих API их эффективное использование остаётся непростой задачей.

Меня зовут Артем Шовкин, я RnD‑разработчик в СберТехе. В процессе изучения MediaStream API наша команда столкнулась с рядом интересных вопросов. Как эффективно управлять параметрами видеопотока в зависимости от возможностей устройства и сети? Какие подводные камни возникают при кроссбраузерной реализации? Как лучше всего обрабатывать ошибки при работе с видеопотоком?

Мы решили не просто разобраться в работе API, но и в деталях изучить спецификацию Media Capture and Streams, чтобы понять, как она используется в реальных приложениях. В статье мы также использовали код исходников реализации getUserMedia.

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

Поехали!

Как видеоконтент появляется в браузере

Когда разработчики слышат про видеопоток в браузере, то часто в первую очередь думают о WebRTC. Но на самом деле спецификация, которая описывает инициализацию медиапотоков, — это Media Capture and Streams. Безусловно, как подмечено на ресурсе MDN, эти спецификации взаимосвязаны и активно друг на друга ссылаются, но реализуются обособленно. Основная их связь в том, что WebRTC — потребитель (или, в терминах спецификации, consumer) исходящих данных MediaStream.

Разберёмся, откуда вообще может появиться видеоконтент в браузере. Источника три:

  • получение при peer‑to‑peer (непосредственная передача данных между браузерами без прослоек в виде сервера);

  • видеофайл (например, в формате mp4 или webm);

  • веб‑камера.

Конечно, есть ещё протоколы HLS (используется в Twitch) и DASH (в YouTube), но это технологии загрузки видео пакетами, а не потоками. Мы де поговорим о получении видео с веб‑камеры.

Для получения видеопотока с устройства основополагающей является эта строчка кода:

await navigator.mediaDevices.getUserMedia({ video: true })

Она фигурирует в каждом ролике или докладе про веб‑камеру в браузере. Казалось бы, просто вызов одного метода в интерфейсе, однако за ним скрывается огромное количество работы.

Описание того, как должен работать getUserMedia, в спецификации объёмное, с множеством ссылок на различные понятия. Суммарно оно состоит из 1507 слов (без референсов), а это четыре страницы текста. К примеру, описание работы стрелочных функций в EcmaScript умещается в 501 слово.

Описание работы getUserMedia сводится к трём основным аспектам, которые напрямую касаются практического применения:

  1. причины реджекта промиса getUserMedia;

  2. использование сonstraints;

  3. алгоритм SelectSettings.

Про них и поговорим далее.

Причины реджекта промиса getUserMedia

Разберём, почему getUserMedia может возвращать ошибку.

  1. Вызов getUserMedia без аргумента. Если запросить доступ к медиаустройствам пользователя (например, веб‑камере и микрофону) через API getUserMedia, но не передать никаких аргументов, то сразу получим reject:

    await navigator.mediaDevices.getUserMedia()

    Обязательно необходимо передавать объект, который включает запрашиваемые медиатипы. Возможны три варианта:

    Если не передать аргументы хотя бы с одним из медиатипов, то getUserMedia вернёт ошибку.

  1. Not fully active document. Второй случай реджекта — ситуация, когда документ не fully active (не полностью активен). Вот как это состояние описывается согласно спецификации HTML:

    7.3.3 Fully active documents

    A Document d is said to be fully active when d is the active document of a navigable navigable, and either navigable is a top‑level traversable or navigable's container document is fully active.

    Взгляните на эти прекрасные формулировки: a navigable navigable, and either navigable. Перевести можно примерно так: «Документ d считается fully active, если d является активным документом навигатора navigable, и либо navigable является обходчиком верхнего уровня,  либо контейнер документа navigable полностью активен». А означает это лишь одно: вкладка должна быть активной. Если вкладка неактивна, а видеопоток не в активном iframe, мы получаем ошибку «DOMException: InvalidStateError».

  1. Нет разрешения пользователя. Третий случай достаточно банальный: пользователь не дал разрешение на использование видеокамер. Тут есть важный момент: если взаимодействуете с периферийными устройствами в браузере, не забывайте работать в защищённом контексте, то есть под HTTPS. Иначе будете получать ошибку.

Что такое constraints и зачем они нужны

Пойдём немного глубже и поговорим о настройке видеопотока. Если вызывать видеопоток стандартно, без каких‑либо настроек: await navigator.mediaDevices.getUserMedia({ video: true }), то мы получим разрешение 640×480. Это базовое разрешение, рекомендованное спецификацией передачи по WebRTC. Если его растянуть на весь экран, изображение будет зернистым.

76c435c9ac095f32355fc0abe53e3248.png

Для того, чтобы изменить качество видео, мы можем передавать объект с настройками. Каждая такая настройка называется constraint.

await navigator.mediaDevice.getUserMedia({ 
    video: {
        width:  1920,
        height: 1080,
        facingMode: 'environment'
    }
})

В примере выше словом constraint обозначаются все параметры — width, height, facingMode. А всё вместе в терминах спецификации — MediaTrackConstraintSet.

Каждый constraint может представлять собой:

  • диапазон значений, который настраивается полями со значениями min и max: width: { min: 1280, max: 1920 }

  • конкретное значение: height: 1080

  • значение из определённого списка enum: facingMode: 'environment'

И тут возникает вопрос: если у нас есть диапазон, то как браузер выбирает конкретное значение? В таком случае он может взять ширину и 1280, и 1440, и 1920 по верхней границе. Для решения такой задачи в спецификации предусмотрен алгоритм SelectSettings.

Описание SelectSettings

Как работает этот алгоритм? Для примера запросим в настройках ширину из диапазона 1280 на 1920. Высоту тоже установим не конкретную, а ограничим сверху: 1080.

await navigator.mediaDevice.getUserMedia({ 
    video: {
        width:  { min: 1280, max: 1920 },
        height: { max: 1080 },
        facingMode: 'environment'
    }
})

Согласно алгоритму SelectSettings, происходит следующее. На основе переданных constraints формируются все возможные настройки, которые будут удовлетворять условиям и не противоречить системным возможностям. Каждый набор таких настроек называется кандидатом, все вместе они представляют набор кандидатов.

Для примера напишем три кандидата (в реальности их получается гораздо больше):

const candidat_1 = {
    width:  1280,
    height: 480
}
const candidat_2 = {
    width:  1280,
    height: 720
}
const candidat_3 = {
    width:  1920,
    height: 1080
}

У браузера также есть настройки по умолчанию. Как правило, следующие:

const defaultVideoSettings = {
    width:  640,
    height: 480,
    aspectRatio: 1.3333333,
    resizeMode: 'none',
    echoCancelation: true
}

Такие настройки рекомендует спецификация как самые подходящие для WebRTC‑соединения.

Далее начинается самое интересное. Браузер рассчитывает fitness distance от настроек по умолчанию до каждого кандидата. Я бы перевёл это как «расстояние соответствия». Для целочисленных constraints оно считается по формуле:

( actual == ideal ) ? 0: | actual — ideal | / max( | actual |, | ideal | )

За actual берётся значение из кандидата, а за ideal — значение из настроек по умолчанию.

До нашего candidat_2 расстояние по ширине — 0, так как у нас диапазон значений ограничен минимальным значением — 1280. По высоте у нас будет расстояние (720 — 480) / 720 = 0.33.

По constraint aspectRatio также будет считаться расстояние по этой формуле, хоть параметр явно и не указан в кандидате. Браузер может установить его значение нехитрым способом: делит ширину и высоту. Таким образом, aspectRatio для candidat_2 равен 1.77, а fitness distance до этого значения будет вычислен следующим образом: (1,77 — 1,33) / 1,77 = 0,25.

Для constraints, значения которых выбираются из enum (все варианты перечисления определены спецификаций), fitness distance считается следующим образом:

( actual == ideal ) ? 0: 1

Constraint resizeModeдля candidat_2также определяется браузером в значении none. Таким образом, расстояние до него равно нулю.

А вот значение echoCancelation браузер не может подсчитать явно, поэтому если такого constraint в кандидате нет, то до него берётся расстояние 0.

Таким образом, по каждому constraint считается расстояние, а затем значения складываются. Аналогично вычисляется fitness distance до каждого кандидата, для candidat_2 он равен 0,58. До candidat_3 fitness distance равен 1.13:

const defaultVideoSettings = {
    width:  1280,            /* 0.33 до 1920 */
    height: 480,             /* 0.55 до 1080 */
    aspectRatio: 1.3333333,  /* 0.25 до 1.77 */
    resizeMode: 'none',      /* 0 */
    echoCancelation: true    /* 0 */
}

Что же с первым кандидатом?  

const defaultVideoSettings = {
    width:  1280,            /* 0   до 1280 */
    height: 480,             /* 0   до 480  */
    aspectRatio: 1.3333333,  /* 0.5 до 2.66 */
    resizeMode: 'none',      /* 0 */
    echoCancelation: true    /* 0 */
}

На первый взгляд — 0,5. Чтобы разобраться, действительно ли это так, запросим точное разрешение 1280×480 у браузера и получим следующую картинку:

d79f2ee85316bb958e76df668c96c358.png

Давайте сравним с нашими целевыми 1920×1080:

b5b0ba3042687048e159b63dc3f95c80.png

Как видите, чтобы браузер смог получить изображение 1280×480, ему приходится обрезать видеопоток снизу и сверху. А это значит, что для первого кандидата браузер выставляет constraint resizeMode в значении crop-and-scale:

const candidat_1 = {
    width:  1280,
    height: 480,
    resizeMode: 'crop-and-scale'
}

И получается, что fitness distance рассчитывается уже со следующими значениями:

const defaultVideoSettings = {
    width:  1280,            /* 0   до 1280 */
    height: 480,             /* 0   до 480  */
    aspectRatio: 1.3333333,  /* 0.5 до 2.66 */
    resizeMode: 'none',      /* 1   до crop-and-scale */
    echoCancelation: true    /* 0 */
}

Итоговое расстояние — 1,5! Резюмируя подсчёты: до первого кандидата расстояние — 1,5; до второго — 0,58; до третьего — 1,13.

Далее браузер выбирает кандидата с наименьшим fitness distance, в нашем случае candidat_2, и возвращает поток с настройками из этого кандидата. То есть при вызове getUserMedia с такими constraints:

await navigator.mediaDevice.getUserMedia({ 
    video: {
        width:  { min: 1280, max: 1920 },
        height: { max: 1080 },
        facingMode: 'environment'
    }
})

вернётся видеопоток со сторонами 1280×720.

Но и тут есть нюанс. Такие значения вернутся в Chrome, так как значение настроек по умолчанию он меняет по нижней границе указанного диапазона. А Safari, напротив, будет брать верхние. То есть в формуле

( actual == ideal ) ? 0 : | actual - ideal | / max( | actual |, | ideal | )

в значение ideal будет подставляться 1920. Следовательно, в Safari побеждает кандидат 3, и getUserMedia вернёт разрешение 1920×1080.

Подведём итоги

Мы рассмотрели с точки зрения спецификации, как браузеры возвращают видеопоток, что такое SelectSettings и как этот параметр помогает определиться с результирующим изображением, основывая свои вычисления на переданных constraints.

Напоследок важный момент: не забывайте про нюансы реализации спецификаций разными браузерами и всегда проверяйте свои проекты на всевозможных устройствах.

Надеюсь, материал был для вас полезным. Спасибо за внимание!

© Habrahabr.ru