Ко всем новостям

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

Публикации в СМИ
20.12.2024
Статья опубликована на Хабре

Веб‑технологии, такие как 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 вернёт ошибку.

2. 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».

3. Нет разрешения пользователя. Третий случай достаточно банальный: пользователь не дал разрешение на использование видеокамер. Тут есть важный момент: если взаимодействуете с периферийными устройствами в браузере, не забывайте работать в защищённом контексте, то есть под 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 оно считается по формуле:

(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.

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 */ }

Как видите, чтобы браузер смог получить изображение 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' } })

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

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

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

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

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

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