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

По сути, WebRTC состоит из двух основных частей:

  1. Методы захвата видео и аудио потоков;

  2. Методы передачи этих потоков между клиентами;

Разберем эти части подробнее.

Захват потоков

В целом про аудио- и видео-захват можно говорить в контексте потоков, так как и те, и другие данные приходят нам в режиме реального времени.

Поток - это последовательность данных, которая может быть обработана по частям, а не целиком. В контексте WebRTC, поток может представлять собой аудио или видео данные, которые передаются в реальном времени и обрабатываются частями.

Современным примером потока являются ответы ИИ. Практически все современные ИИ не генерируют ответ текста целиком и сразу, они производят генерацию по токенам, передавая получившийся результат по частям. Это позволяет пользователю видеть ответ быстрее, а также дает возможность прервать генерацию, если ответ не удовлетворяет ожиданиям.

В контексте WebRTC у нас есть два основных типа потоков:

  1. Аудио поток - это последовательность аудио данных, которые могут быть захвачены с микрофона пользователя или получены из другого источника.

  2. Видео поток - это последовательность видео данных, которые могут быть захвачены с камеры пользователя или получены из другого источника (например, демонстрация экрана).

Все устройства, которые могут захватывать такие данные называются медиа-устройствами.

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

Для того чтобы запросить доступ к медиа-устройствам, мы можем использовать метод getUserMedia, который возвращает промис с объектом MediaStream. Этот объект представляет собой поток аудио и/или видео данных:

navigator.mediaDevices
	.getUserMedia({ audio: true, video: true })
	.then((stream) => {
		// Здесь мы можем использовать полученный поток
		console.log('Получен поток:', stream);
	})
	.catch((error) => {
		console.error('Ошибка при получении медиа-потока:', error);
	});

После выполнения данного кода, браузер запросит у пользователя разрешение на доступ к камере и микрофону. Если пользователь согласится, мы получим объект MediaStream, который содержит аудио и видео данные.

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

Пока что, это все что мы должны знать про захват потоков. После того, как мы получили потоки, мы можем делать с ними все, что угодно: воспроизводить их в <video> или <audio> элементах, обрабатывать с помощью Web Audio API, и т.д.

Для примера, давайте выведем картинку с камеры через видео-поток в элемент <video>:

<video autoplay playsinline controls="false" id="cam-video-stream"></video>
const videoEl = document.getElementById('cam-video-stream');

try {
	const constraints = { video: true };
	const stream = navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
		videoEl.srcObject = stream;
	});
} catch (error) {
	console.error('Error opening video stream from camera.', error);
}

Обратите внимание на то, какие атрибуты мы указали у элемента <video>:

  • autoplay - позволяет видео начинать воспроизводиться автоматически, как только поток будет готов;

  • playsinline - позволяет видео воспроизводиться встраиваемым образом, без перехода в полноэкранный режим на мобильных устройствах;

  • controls="false" - отключает стандартные элементы управления видео, чтобы мы могли управлять воспроизведением самостоятельно, если это потребуется.

Устройства

До текущего момента мы рассматривали только захват потоков со стандартных медиа-устройств. В большинстве приложений есть селект для выбора устройства, с которого будет производиться захват. Например, если у пользователя есть несколько камер или микрофонов, он может выбрать нужное устройство.

Для того чтобы получить все медиа-устройства, к которым браузер имеет доступ, мы можем использовать метод enumerateDevices из API MediaDevices. Этот метод возвращает промис с массивом объектов MediaDeviceInfo, которые содержат информацию о каждом устройстве:

navigator.mediaDevices.enumerateDevices().then((devices) => {
	console.log('Список медиа-устройств:', devices);
});
// Вывод
Список медиа-устройств:
[
  {
    "deviceId": "default",
    "kind": "audioinput",
    "label": "Default - Микрофон MacBook Pro (Built-in)",
    "groupId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  },
  {
    "deviceId": "efd16f99b50d66de56fd4a2b247a5bb4cc64feee924a99879e08486834355dbb",
    "kind": "audioinput",
    "label": "Микрофон MacBook Pro (Built-in)",
    "groupId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  },
  {
    "deviceId": "871a76159da73ea7eb2bfa3cd39ad555692476a29d5503b3b939c7ab2700ff1b",
    "kind": "videoinput",
    "label": "HD-камера FaceTime",
    "groupId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  },
  // ...
]

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

let devices = [];

// Получаем список медиа-устройств при загрузке страницы
navigator.mediaDevices.enumerateDevices().then((newDevices) => {
	devices = newDevices;
	console.log('Список медиа-устройств:', devices);
});

// Устанавливаем обработчик события, который будет вызываться при изменении доступного списка устройств
navigator.mediaDevices.addEventListener('devicechange', () => {
	navigator.mediaDevices.enumerateDevices().then((newDevices) => {
		devices = newDevices;
		console.log('Обновленный список медиа-устройств:', devices);
	});
});

Выбор устройства и объект ограничений

Ранее, при вызове getUserMedia, мы указывали объект который содержал желаемые потоки:

// Указываем какие именно потоки мы хотим получить
const constraints = { audio: true, video: true };
navigator.mediaDevices.getUserMedia(constraints);

Данный объект часто называют ограничением потоков (constraints), ибо он (в простой версии) позволяет ограничить используемые потоки. По умолчанию getUserMedia вернет нам потоки с устройств, которые выбраны по умолчанию в настройках браузера, однако, объект ограничений позволяет нам указать не только конкретные потоки, но и ограничить устройства, с которых будут писаться потоковые данные:

const devices = await navigator.mediaDevices.enumerateDevices();
const streams = await navigator.mediaDevices
	.getUserMedia({
		audio: {
			deviceId: devices[0].deviceId // Указываем конкретное устройство для аудио
		},
		video: {
			deviceId: devices[1].deviceId // Указываем конкретное устройство для видео
		}
	})
	.catch((error) => {
		console.error('Ошибка при получении медиа-потока:', error);
	});

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

Что касательно объекта ограничений, то он описан в спецификации Media Capture and Streams, где он называется MediaStreamConstraints. В спецификации также описаны все возможные свойства, которые можно использовать в этом объекте.

Помимо того, что мы можем указать конкретные устройства, мы также можем указать другие параметры, такие как разрешение видео, частота кадров и т.п.
По возможности, всегда стоит уточнять лучшие параметры для захвата видео и аудио, так как это может значительно улучшить качество получаемых данных.

const constraints = {
	audio: {
		deviceId: 'default', // Используем устройство по умолчанию для аудио
		echoCancellation: true // Включаем подавление эха
	},
	video: {
		width: { min: 600, ideal: 1280 }, // Минимальная и идеальная ширина видео
		height: { min: 400, ideal: 720 }, // Минимальная и идеальная высота видео
		frameRate: { ideal: 30 } // Идеальная частота кадров
	}
};

Захват экрана

Для захвата экрана мы можем использовать метод getDisplayMedia, который работает аналогично getUserMedia, но позволяет захватывать содержимое экрана или отдельных окон/вкладок:

<video autoplay playsinline controls="false" id="cam-video-stream"></video>
const videoEl = document.getElementById('cam-video-stream');

try {
	const constraints = { video: true };
	// [!code highlight]
	const stream = navigator.mediaDevices.getDisplayMedia(constraints).then((stream) => {
		videoEl.srcObject = stream;
	});
} catch (error) {
	console.error('Error opening video stream from camera.', error);
}

Треки потока

Трек (они же дорожки) - это отдельный поток данных, который может быть аудио или видео.

Когда мы получаем поток с помощью getUserMedia, который содержит как аудио, так и видео данные, мы можем получить доступ к отдельным трекам этого потока. В случае если мы запросили только аудио или только видео, то у нас будет только один трек.

Треки мы можем получить из объекта MediaStream с помощью свойства getTracks(), которое возвращает массив всех треков в потоке:

const constraints = { video: true };
const stream = await navigator.mediaDevices.getUserMedia(constraints);
console.log(stream.getTracks());
[
	{
		"contentHint": "",
		"enabled": true,
		"id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
		"kind": "video",
		"label": "HD-камера FaceTime",
		"muted": false,
		"oncapturehandlechange": null,
		"onended": null,
		"onmute": null,
		"onunmute": null,
		"readyState": "live"
	}
	// <При условии, если мы запросили бы аудио, то тут был бы второй трек>
	// ...
]

Мы можем управлять каждым треком отдельно, например, отключать или включать его:

const tracks = stream.getTracks();
// Отключаем трек
tracks[0].enabled = false;
// Включаем трек
tracks[0].enabled = true;

Или же мы и вовсе можем остановить все треки. К слову кнопка "Прекратить трансляцию" в примерах выше, сделана следующим образом, данный сниппет останавливает все треки в потоке:

stream.getTracks().forEach((track) => track.stop());

Сами треки в спецификации называются MediaStreamTrack, их можно найти вот тут.

Соединение

Каждое соединение в WebRTC состоит из трех основных частей:

  1. Сигналинг нужен для того, чтобы установить первоначальное соединение между клиентами;

  2. Создание Peer Connection нужно для создания объекта соединения, который будет использоваться для обмена данными;

  3. Обмен офферами нужен для обмена информацией о медиа-потоках и установления соединения;

Внизу мы рассмотрим каждый из этих этапов более подробно.

Сигналинг

Сигналинг - это процесс обмена сообщениями между клиентами для установления соединения, обмена информацией о медиа-потоках, а также для передачи других данных, необходимых для работы WebRTC.

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

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

Что такое Peer Connection?

Peer Сonnections — это часть технологии WebRTC, которая позволяет двум приложениям (например, браузерам на разных компьютерах) напрямую обмениваться видео, аудио или бинарными данными.

Такое соединение работает по принципу “peer-to-peer” (от клиента к клиенту), без центрального сервера для передачи данных.

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

Не бойтесь аббревиатуры ICE, в следующем разделе мы подробно рассмотрим, что это такое и зачем оно нужно.

Пока что стоит знать, что для установления соединения, нам нужно указать хотя бы один ICE-сервер

// Конфигурация ICE-серверов
const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };

// Инициализация объекта RTCPeerConnection
const peerConnection = new RTCPeerConnection(configuration);

Объект RTCPeerConnection описан в спецификации WebRTC.

ICE

ICE (Internet Connection Establishment) - это процесс установления пути, по которому два клиента смогут связаться с друг-другом, даже если они находятся за NAT или файрволом.

Сам процесс ICE обычно подразумевает под собой получения или передачу данных на один из двух серверов ниже:

  1. STUN (Session Traversal Utilities for NAT) - серверы, которые помогают клиентам определить свой публичный IP-адрес и порт, а также позволяют клиентам обмениваться этой информацией друг с другом.

  2. TURN (Traversal Using Relays around NAT) - серверы, которые могут использоваться для передачи данных между клиентами, если прямое соединение не удалось установить. TURN серверы выступают в роли промежуточного узла, через который проходят данные.

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

Список доступных IP-адресов и портов, которые могут быть использованы для соединения, называют ICE-кандидатами.

В сниппете кода с созданием RTCPeerConnection из раздела выше мы указали только один STUN-сервер, который будет использоваться для установления соединения.

В реальных приложениях обычно используется несколько STUN и TURN серверов, чтобы обеспечить надежное соединение в различных сетевых условиях.

Популярными (и бесплатными) вариантами для получения возможных путей для соединения являются следующие STUN-серверы:

  • stun.l.google.com:19302

  • stun.speedy.com.ar:3478

  • stun.nextcloud.com:443

  • stun.ideasip.com:3478

  • stun.imesh.com:3478

  • stun.infra.net:3478

Ну, и не забудем про пример, как именно использовать STUN и TURN серверы в RTCPeerConnection:

const configuration = {
	iceServers: [
		// STUN серверы
		{
			urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302']
		},
		// TURN сервер
		{
			urls: 'turn:turn.example.com:3478',
			username: 'username',
			credential: 'password'
		}
	]
};

const peerConnection = new RTCPeerConnection(configuration);

Зачастую TURN-сервера селф-хостят, с помощью таких решений как Coturn.

Обмен ICE-кандидатами

После того как мы создали объект RTCPeerConnection, нам нужно обменяться ICE-кандидатами между клиентами, чтобы они могли установить соединение.

По умолчанию ICE-кандидаты не отправляются автоматически между клиентами. Вместо этого, после создания RTCPeerConnection, нам нужно подписаться на событие icecandidate, которое будет вызываться каждый раз, когда новый ICE-кандидат будет найден:

// Конфигурация ICE-серверов
const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };

// Инициализация объекта RTCPeerConnection
const peerConnection = new RTCPeerConnection(configuration);

// [!code highlight:3]
// Подписываемся на событие icecandidate, которое будет вызываться каждый раз,
// когда новый ICE-кандидат будет найден
peerConnection.addEventListener('icecandidate', (event) => {
	if (event.candidate) {
		// [!code highlight:2]
		// Отправляем ICE-кандидата другому клиенту через сигналинг
		ws.send(clientId, 'new-ice-candidate', event.candidate);
	}
});

Другой клиент, в это же время должен подписаться на событие new-ice-candidate, чтобы получить ICE-кандидаты от первого клиента и установить их в своем RTCPeerConnection:

// Конфигурация ICE-серверов
const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };

// Инициализация объекта RTCPeerConnection
const peerConnection = new RTCPeerConnection(configuration);

ws.on('new-ice-candidate', async (message) => {
	if (message.iceCandidate) {
		try {
			// [!code highlight:2]
			// Устанавливаем полученный ICE-кандидат в RTCPeerConnection
			await peerConnection.addIceCandidate(message.iceCandidate);
		} catch (e) {
			console.error('Error adding received ice candidate', e);
		}
	}
});

Офферы (SDP)

SDP (Session Description Protocol) - это формат, который используется для описания медиа-сессии, включая информацию о кодеках, разрешении видео, частоте кадров и других параметрах.

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

После инициализации RTCPeerConnection, нам нужно обменяться офферами и ответами, которые будут содержать SDP информацию. Этот процесс называется обмен офферами, вот как его можно реализовать:

const peerConnection = new RTCPeerConnection(configuration);

// Создаем оффер
peerConnection
	.createOffer()
	.then((offer) => {
		// Устанавливаем оффер в качестве локального описания
		return peerConnection.setLocalDescription(offer);
	})
	.then(() => {
		// Отправляем оффер другому клиенту через сигналинг
		ws.send(clientId, 'sending-offer', peerConnection.localDescription);
	})
	.catch((error) => {
		console.error('Ошибка при создании оффера:', error);
	});

Как мы можем видеть, нам не нужно самостоятельно формировать SDP, так как WebRTC занимается этим за нас и автоматически генерирует список доступных потоков и кодеков, которые могут быть использованы для соединения.

На принимающей оффер стороне мы должны установить полученный оффер в качестве удаленного описания, а затем создать ответ:

const peerConnection = new RTCPeerConnection(configuration);

ws.on('send-offer', (offer) => {
	peerConnection
		.setRemoteDescription(offer)
		.then(() => {
			// Создаем ответ на оффер
			return peerConnection.createAnswer();
		})
		.then((answer) => {
			// Устанавливаем ответ в качестве локального описания
			return peerConnection.setLocalDescription(answer);
		})
		.then(() => {
			// Отправляем ответ обратно отправителю оффера
			ws.send(clientId, 'sending-answer', peerConnection.localDescription);
		})
		.catch((error) => {
			console.error('Ошибка при обработке оффера:', error);
		});
});

Сам процесс обмена офферами можно описать визуально следующим образом:

Объект, который будет описывать сессию (SDP) называется RTCSessionDescription. Он описан в спецификации WebRTC.

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

На текущий момент мы научились создавать RTCPeerConnection, находить ICE-кандидатов и добавлять их в соединение, а также обмениваться офферами и ответами. Теперь мы можем перейти к управлению потоками и треками.

В целом флоу (без потоков) выглядит следующим образом:

По сути, когда два и более клиента обмениваются офферами и ответами, они могут начать обмениваться медиа-потоками. Для этого нам нужно добавить треки в RTCPeerConnection, которые будут представлять собой аудио и видео данные:

const peerConnection = new RTCPeerConnection(configuration);

// Получаем поток с камеры
navigator.mediaDevices
	.getUserMedia({ audio: true, video: true })
	.then((stream) => {
		// Добавляем аудио и видео треки в RTCPeerConnection
		stream.getTracks().forEach((track) => {
			peerConnection.addTrack(track, stream);
		});
	})
	.catch((error) => {
		console.error('Ошибка при получении медиа-потока:', error);
	});

// Подписываемся на событие track, чтобы получать удаленные треки
peerConnection.addEventListener('track', (event) => {
	const remoteStream = event.streams[0];
	// Здесь мы можем использовать удаленный поток, например, воспроизвести его в видео элементе
	const videoEl = document.getElementById('remote-video');
	videoEl.srcObject = remoteStream;
});

Для того чтобы получать удаленные треки (треки других клиентов), нам необходимо подписаться на событие track, которое будет вызываться каждый раз, когда новый трек будет добавлен в RTCPeerConnection. В этом случае мы можем получить удаленный поток и воспроизвести его в видео элементе.

Добавлять потоки можно как до обмена офферами, так и после.

После поиска ICE-кандидатов, их передачи друг другу, добавления треков в RTCPeerConnection, и обмена офферами, мы можем начать обмениваться аудио и видео данными.

Динамическое отключение треков

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

Для того чтобы динамически изменять состояния треков, мы можем использовать метод getSenders у RTCPeerConnection, который возвращает массив RTCRtpSender объектов, представляющих треки, которые были добавлены в RTCPeerConnection. Мы можем использовать этот метод для получения отправителей и управления их состоянием:

const peerConnection = new RTCPeerConnection(configuration);

const turnOffMicrophone = () => {
	// Получаем треки из потока
	const tracks = peerConnection.getSenders().find((sender) => sender.track.kind === 'audio');
	if (tracks) {
		// Отключаем микрофон
		tracks.track.enabled = false;
	}
};

const turnOnMicrophone = () => {
	// Получаем треки из потока
	const tracks = peerConnection.getSenders().find((sender) => sender.track.kind === 'audio');
	if (tracks) {
		// Включаем микрофон
		tracks.track.enabled = true;
	}
};

Динамическое добавление новых треков

Если у нас есть необходимость добавить новый трек в RTCPeerConnection после обмена офферами, мы можем сделать это следующим образом:

const peerConnection = new RTCPeerConnection(configuration);

navigator.mediaDevices
	.getUserMedia({ audio: true, video: true })
	.then((stream) => {
		// Получаем новый трек из потока
		const newTrack = stream.getTracks()[0];
		// Добавляем новый трек в RTCPeerConnection
		const sender = peerConnection.addTrack(newTrack, stream);
		console.log('Новый трек добавлен:', sender);
	})
	.catch((error) => {
		console.error('Ошибка при получении медиа-потока:', error);
	});

Остановка соединения

Для того чтобы остановить соединение и освободить ресурсы, нам нужно остановить все треки в RTCPeerConnection и закрыть его:

const peerConnection = new RTCPeerConnection(configuration);

// Останавливаем все треки
peerConnection.getSenders().forEach((sender) => {
	sender.track.stop();
});
// Закрываем RTCPeerConnection
peerConnection.close();

Групповые звонки и их топология

Для начала, напомню, что каждый клиент в WebRTC соединен с друг-другом с помощью Peer-to-Peer соединения (его ещё называют P2P).

В частном случае, когда у нас есть всего два клиента, топология соединения выглядит следующим образом:

Однако, когда речь заходит про групповые звонки, то топология соединений радикально изменяется, причем не в самую лучшую сторону, так как каждый из клиентов должен поддерживать соединение с каждым из других клиентов, что приводит к экспоненциальному росту количества соединений:

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

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

SFU и MCU

SFU (Selective Forwarding Unit) представляет собой сервер-маршрутизатор, который получает медиапотоки от всех участников и перенаправляет их другим участникам. При этом:

  • Сервер не обрабатывает медиаданные, а только перенаправляет их

  • Каждый клиент отправляет только один поток на сервер

  • Сервер может выборочно решать, какие потоки отправлять каждому участнику

  • Возможна адаптация качества потока под возможности получателя

Главное преимущество SFU — низкая задержка, так как отсутствует обработка медиа на сервере. Кроме того, нагрузка на клиентские устройства значительно ниже, чем при mesh-топологии, хотя всё ещё требуется декодировать несколько входящих потоков.

Среди популярных решений можно выделить следующие:

MCU (Multipoint Control Unit) — это более сложное серверное решение, которое не просто перенаправляет потоки, а обрабатывает их. MCU обеспечивает следующие возможности:

  • Принимает все входящие потоки и декодирует их

  • Комбинирует потоки (например, микширует аудио)

  • Создает единый выходной поток для каждого участника

  • Клиенты получают только один поток, независимо от количества участников

  • Минимальная нагрузка на клиентские устройства

  • Работает даже на слабых устройствах

  • Меньше требований к пропускной способности клиента

Однако у этого подхода есть и недостатки:

  • Более высокая задержка из-за обработки медиа

  • Высокие требования к серверным ресурсам

  • Более дорогое решение в плане инфраструктуры

Топология MCU выглядит так же, как и топология SFU:

На практике часто используются гибридные решения. Например, можно использовать SFU для видео и MCU для аудио, или динамически переключаться между режимами в зависимости от ситуации.

Вместо заключения

Если вам было интересно читать данную статью, то возможно вам понравятся и другие мои статьи. Вы можете найти их в телеге или в моём блоге.

Надеюсь смог рассказать что-то новое и интересное, хорошего дня!✨

Комментарии (0)