Как эффективно управлять видеопотоком с веб-камеры в браузере
Веб‑технологии, такие как 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
сводится к трём основным аспектам, которые напрямую касаются практического применения:
причины реджекта промиса
getUserMedia
;использование сonstraints;
алгоритм SelectSettings.
Про них и поговорим далее.
Причины реджекта промиса getUserMedia
Разберём, почему getUserMedia
может возвращать ошибку.
Вызов getUserMedia без аргумента. Если запросить доступ к медиаустройствам пользователя (например, веб‑камере и микрофону) через API
getUserMedia
, но не передать никаких аргументов, то сразу получим reject:await navigator.mediaDevices.getUserMedia()
Обязательно необходимо передавать объект, который включает запрашиваемые медиатипы. Возможны три варианта:
Если не передать аргументы хотя бы с одним из медиатипов, то
getUserMedia
вернёт ошибку.
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».
Нет разрешения пользователя. Третий случай достаточно банальный: пользователь не дал разрешение на использование видеокамер. Тут есть важный момент: если взаимодействуете с периферийными устройствами в браузере, не забывайте работать в защищённом контексте, то есть под HTTPS. Иначе будете получать ошибку.
Что такое constraints и зачем они нужны
Пойдём немного глубже и поговорим о настройке видеопотока. Если вызывать видеопоток стандартно, без каких‑либо настроек: await navigator.mediaDevices.getUserMedia({ video: true })
, то мы получим разрешение 640×480. Это базовое разрешение, рекомендованное спецификацией передачи по WebRTC. Если его растянуть на весь экран, изображение будет зернистым.
Для того, чтобы изменить качество видео, мы можем передавать объект с настройками. Каждая такая настройка называется 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 оно считается по формуле:
За берётся значение из кандидата, а за — значение из настроек по умолчанию.
До нашего 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 считается следующим образом:
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 у браузера и получим следующую картинку:
Давайте сравним с нашими целевыми 1920×1080:
Как видите, чтобы браузер смог получить изображение 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, напротив, будет брать верхние. То есть в формуле
в значение будет подставляться 1920. Следовательно, в Safari побеждает кандидат 3, и getUserMedia
вернёт разрешение 1920×1080.
Подведём итоги
Мы рассмотрели с точки зрения спецификации, как браузеры возвращают видеопоток, что такое SelectSettings
и как этот параметр помогает определиться с результирующим изображением, основывая свои вычисления на переданных constraints.
Напоследок важный момент: не забывайте про нюансы реализации спецификаций разными браузерами и всегда проверяйте свои проекты на всевозможных устройствах.
Надеюсь, материал был для вас полезным. Спасибо за внимание!