WebRTC функционирует поверх транспортного протокола UDP с шифрованием DTLS (Datagram Transport Layer Security) и SRTP (Secure Real-time Transport Protocol).
Установление прямого соединения между двумя конечными точками требует прохождения трех ключевых фаз:
┌─────────────────────────────────────────────────────────────────────────────┐
│ TOPOLOGY A: FULL MESH (P2P, N <= 4) │
│ │
│ [Peer 1] ◄═══════════════ (RTP/SRTP) ═══════════════► [Peer 2] │
│ ▲ ╲ ╱ ▲ │
│ ║ ╲ ╱ ║ │
│ ║ ╲ ╱ ║ │
│ ║ ▼ ▼ ║ │
│ [Peer 3] ◄═══════════════ (RTP/SRTP) ═══════════════► [Peer 4] │
│ │
│ Каждый клиент: (N - 1) энкодеров видео, N * (N - 1) потоков в сети │
│ При N = 6: 30 потоков, перегрев CPU/GPU, деградация канала отдачи (Uplink)│
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ TOPOLOGY B: SFU (Selective Forwarding Unit) │
│ │
│ [Peer 1] ──(1 Uplink: Simulcast 1080p/720p/360p)──► ┌─────────────────┐ │
│ [Peer 2] ──(1 Uplink)─────────────────────────────► │ SFU SERVER │ │
│ [Peer 3] ──(1 Uplink)─────────────────────────────► │(LiveKit/Mediasoup) │
│ └────────┬────────┘ │
│ │ │
│ ◄─── (N - 1 Downlinks per client) ──────────┘ │
│ │
│ Каждый клиент: ровно 1 энкодер видео! Сервер маршрутизирует пакеты │
│ без перекодирования. Масштабируемость до сотен активных участников │
└─────────────────────────────────────────────────────────────────────────────┘Главное ограничение чистого P2P (Full Mesh) — экспоненциальный рост нагрузки на процессор и сеть: $O(N^2)$ соединений в комнате. Уже при 5 участниках клиентский ноутбук вынужден одновременно аппаратно сжимать и передавать 4 независимых видеопотока, что приводит к дропу кадров и разрыву связи.
Решением для масштабируемых видеоконференций является архитектура SFU (Selective Forwarding Unit) (на базе LiveKit, Mediasoup, Pion). Клиент публикует ровно один восходящий поток (Uplink), а сервер распределяет слои разрешения (Simulcast) подписчикам в зависимости от размера их видеоокон в UI и стабильности сети.
В React 19 работа с WebRTC строится на изоляции императивных мутаций аппаратных треков от декларативного дерева компонентов:
track.enabled = false/true или вызов track.stop().user-joined, offer, answer, ice-candidate). Сетевые пакеты аудио/видео движутся напрямую по протоколу UDP в обход сервера приложений.<video> обязан иметь атрибут muted={true}. Несоблюдение этого правила вызывает положительную акустическую обратную связь (эффект Ларсена / свист динамиков).Вызов navigator.mediaDevices.getUserMedia() с параметрами разрешения, частоты кадров и шумоподавления (echoCancellation, noiseSuppression).
Установление WebSocket-сессии с SFU (LiveKit/Mediasoup), получение Room Token и конфигурации STUN/TURN.
Создание sendTransport (для публикации локальных треков) и recvTransport (для приема удаленных треков).
Добавление треков в sendTransport с генерацией трех профилей разрешения (1080p, 720p, 360p) для адаптации под клиентов.
Монтирование входящих MediaStream в удаленные элементы <video> с отслеживанием состояний подключения.
Команда разрабатывает видеочат. До 4 участников видео работает плавно, но при добавлении 6-го участника вентиляторы ноутбуков начинают шуметь на максимуме, а битрейт видео падает до нуля. Архитектура построена на прямых соединениях RTCPeerConnection между всеми участниками (Full Mesh). В чем фундаментальная причина проблемы?
Корпоративные платформы связи (на базе LiveKit / Mediasoup) с десятками одновременных спикеров, адаптивным выбором активного докладчика (Active Speaker Detection) и сеткой слушателей.
Лекционные залы с сотнями студентов: лектор вещает в Full HD с микросекундной задержкой, студенты поднимают руку, кратковременно подключая свои аудио/видеотреки без срыва вещания.
Стриминг дисплея рабочего стола (getDisplayMedia) с оптимизацией под мелкий текст и код (профиль contentHint = 'text'), динамическим изменением частоты кадров от 5 FPS (статичные слайды) до 60 FPS (анимации).
Видеосвязь повышенной надежности с обязательным сквозным шифрованием (E2EE — End-to-End Encryption на базе WebRTC Insertable Streams / SFrame) для соблюдения стандартов HIPAA/GDPR.
Захват экрана осуществляется через navigator.mediaDevices.getDisplayMedia. При этом крайне важно устанавливать свойство contentHint:
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
frameRate: { max: 30 },
displaySurface: 'monitor',
},
audio: true,
});
const videoTrack = screenStream.getVideoTracks()[0];
if ('contentHint' in videoTrack) {
// Сообщает энкодеру отдавать приоритет резкости деталей, а не плавности движения
videoTrack.contentHint = 'text';
}'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
export interface UserMediaState {
stream: MediaStream | null;
isAudioEnabled: boolean;
isVideoEnabled: boolean;
isScreenSharing: boolean;
error: string | null;
}
export function useUserMedia() {
const [state, setState] = useState<UserMediaState>({
stream: null,
isAudioEnabled: true,
isVideoEnabled: true,
isScreenSharing: false,
error: null,
});
const streamRef = useRef<MediaStream | null>(null);
const screenTrackRef = useRef<MediaStreamTrack | null>(null);
// Инициализация локальной камеры и микрофона
const startMedia = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30, max: 30 },
},
});
streamRef.current = stream;
setState((prev) => ({
...prev,
stream,
isAudioEnabled: true,
isVideoEnabled: true,
error: null,
}));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Ошибка доступа к медиаустройствам';
setState((prev) => ({ ...prev, error: message }));
}
}, []);
// Переключение микрофона (Mute) без перезапуска аппаратного трека
const toggleAudio = useCallback(() => {
if (!streamRef.current) return;
const audioTrack = streamRef.current.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
setState((prev) => ({ ...prev, isAudioEnabled: audioTrack.enabled }));
}
}, []);
// Переключение видеокамеры
const toggleVideo = useCallback(() => {
if (!streamRef.current) return;
const videoTrack = streamRef.current.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
setState((prev) => ({ ...prev, isVideoEnabled: videoTrack.enabled }));
}
}, []);
// Запуск демонстрации экрана с подменой видеотрека
const startScreenShare = useCallback(async () => {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: { max: 30 } },
audio: false,
});
const screenTrack = screenStream.getVideoTracks()[0];
screenTrackRef.current = screenTrack;
// Обработка клика пользователем по нативной кнопке "Прекратить доступ"
screenTrack.onended = () => {
stopScreenShare();
};
setState((prev) => ({ ...prev, isScreenSharing: true }));
return screenTrack;
} catch (err) {
console.warn('Screen share canceled or denied:', err);
return null;
}
}, []);
const stopScreenShare = useCallback(() => {
if (screenTrackRef.current) {
screenTrackRef.current.stop();
screenTrackRef.current = null;
}
setState((prev) => ({ ...prev, isScreenSharing: false }));
}, []);
// Детерминированная очистка аппаратных ресурсов при размонтировании
useEffect(() => {
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
}
if (screenTrackRef.current) {
screenTrackRef.current.stop();
}
};
}, []);
return {
...state,
startMedia,
toggleAudio,
toggleVideo,
startScreenShare,
stopScreenShare,
};
}'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
export interface ParticipantStream {
peerId: string;
stream: MediaStream;
isAudioMuted: boolean;
isVideoMuted: boolean;
}
export function useSfuRoom(roomId: string, localStream: MediaStream | null) {
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const [connectionState, setConnectionState] = useState<RTCPeerConnectionState>('new');
const [remoteParticipants, setRemoteParticipants] = useState<Map<string, ParticipantStream>>(new Map());
// Процедура восстановления связи (ICE Restart)
const triggerIceRestart = useCallback(async () => {
const pc = peerConnectionRef.current;
if (!pc) return;
try {
console.info('[SFU] Initiating ICE Restart due to network handover...');
// Создание Offer с явным флагом перезапуска ICE
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
// В реальном приложении: отправка offer через WebSocket на SFU-сервер
// socket.emit('signal', { type: 'ice-restart-offer', sdp: offer.sdp });
} catch (err) {
console.error('[SFU] ICE Restart failed:', err);
}
}, []);
useEffect(() => {
if (!localStream) return;
// 1. Конфигурация с STUN/TURN серверами
const rtcConfig: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// { urls: 'turn:turn.domain.com:3478', username: '...', credential: '...' }
],
iceCandidatePoolSize: 2,
bundlePolicy: 'max-bundle', // Мультиплексирование всех треков в один UDP-порт
};
const pc = new RTCPeerConnection(rtcConfig);
peerConnectionRef.current = pc;
// 2. Добавление локальных треков с настройкой Simulcast
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// 3. Обработка входящих удаленных треков от SFU
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
if (!remoteStream) return;
const peerId = remoteStream.id;
setRemoteParticipants((prev) => {
const next = new Map(prev);
next.set(peerId, {
peerId,
stream: remoteStream,
isAudioMuted: false,
isVideoMuted: false,
});
return next;
});
};
// 4. Мониторинг стабильности соединения
pc.onconnectionstatechange = () => {
setConnectionState(pc.connectionState);
if (pc.connectionState === 'failed') {
// Обрыв сети (переход с Wi-Fi на LTE) -> запуск ICE Restart
triggerIceRestart();
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected') {
console.warn('[SFU] ICE connection disconnected, waiting for auto-heal...');
}
};
return () => {
pc.close();
peerConnectionRef.current = null;
setRemoteParticipants(new Map());
};
}, [localStream, triggerIceRestart]);
return {
connectionState,
remoteParticipants: Array.from(remoteParticipants.values()),
triggerIceRestart,
};
}'use client';
import * as React from 'react';
import { useUserMedia } from '../hooks/useUserMedia';
import { useSfuRoom } from '../hooks/useSfuRoom';
export function VideoConferenceRoom({ roomId }: { roomId: string }) {
const {
stream: localStream,
isAudioEnabled,
isVideoEnabled,
isScreenSharing,
startMedia,
toggleAudio,
toggleVideo,
startScreenShare,
stopScreenShare,
error,
} = useUserMedia();
const { connectionState, remoteParticipants, triggerIceRestart } = useSfuRoom(
roomId,
localStream
);
const localVideoRef = React.useRef<HTMLVideoElement | null>(null);
// Привязка локального потока к HTMLVideoElement
React.useEffect(() => {
if (localVideoRef.current && localStream) {
localVideoRef.current.srcObject = localStream;
}
}, [localStream]);
return (
<div className="flex h-screen w-full flex-col bg-[#0b0c14] text-white">
{/* Верхняя панель статуса */}
<div className="flex items-center justify-between border-b border-border/50 px-6 py-3">
<div className="flex items-center gap-3">
<div
className={`h-2.5 w-2.5 rounded-full ${
connectionState === 'connected' ? 'bg-success animate-pulse' : 'bg-warning'
}`}
/>
<span className="font-mono text-xs uppercase tracking-wider text-text-muted">
Комната: {roomId} • Статус: {connectionState}
</span>
</div>
{connectionState === 'failed' && (
<button
onClick={triggerIceRestart}
className="rounded bg-danger/20 border border-danger/40 px-3 py-1 text-xs text-danger"
>
Восстановить соединение (ICE Restart)
</button>
)}
</div>
{error && (
<div className="bg-danger/10 border-b border-danger/20 px-6 py-2 text-xs text-danger">
{error}
</div>
)}
{/* Адаптивная видеосетка */}
<div className="grid flex-1 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4 overflow-y-auto">
{/* Локальное окно участника (обязательно muted={true}!) */}
<div className="relative aspect-video overflow-hidden rounded-xl border border-accent-cyan/30 bg-black">
{localStream ? (
<video
ref={localVideoRef}
autoPlay
playsInline
muted // Предотвращает акустический свист микрофона
className="h-full w-full object-cover -scale-x-100" // Зеркальное отображение своей камеры
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<button
onClick={startMedia}
className="rounded-lg bg-accent-cyan px-4 py-2 font-semibold text-black text-sm"
>
Подключить камеру
</button>
</div>
)}
<span className="absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs font-mono">
Вы {!isVideoEnabled && '(Камера выкл)'}
</span>
</div>
{/* Сетка удаленных участников */}
{remoteParticipants.map((participant) => (
<RemoteVideoCard key={participant.peerId} participant={participant} />
))}
</div>
{/* Нижняя панель управления дорожками */}
<div className="flex items-center justify-center gap-3 border-t border-border/50 bg-[#0f101a] py-4">
<button
onClick={toggleAudio}
className={`rounded-full px-4 py-2 text-xs font-semibold transition-colors ${
isAudioEnabled ? 'bg-white/10 hover:bg-white/20' : 'bg-danger text-white'
}`}
>
{isAudioEnabled ? 'Микрофон ВКЛ' : 'Микрофон ВЫКЛ'}
</button>
<button
onClick={toggleVideo}
className={`rounded-full px-4 py-2 text-xs font-semibold transition-colors ${
isVideoEnabled ? 'bg-white/10 hover:bg-white/20' : 'bg-danger text-white'
}`}
>
{isVideoEnabled ? 'Камера ВКЛ' : 'Камера ВЫКЛ'}
</button>
<button
onClick={isScreenSharing ? stopScreenShare : startScreenShare}
className={`rounded-full px-4 py-2 text-xs font-semibold transition-colors ${
isScreenSharing ? 'bg-accent-violet text-white' : 'bg-white/10 hover:bg-white/20'
}`}
>
{isScreenSharing ? 'Остановить экран' : 'Демонстрация экрана'}
</button>
</div>
</div>
);
}
function RemoteVideoCard({ participant }: { participant: { peerId: string; stream: MediaStream } }) {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
React.useEffect(() => {
if (videoRef.current) {
videoRef.current.srcObject = participant.stream;
}
}, [participant.stream]);
return (
<div className="relative aspect-video overflow-hidden rounded-xl border border-border bg-black">
<video
ref={videoRef}
autoPlay
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute bottom-2 left-2 rounded bg-black/60 px-2 py-0.5 text-xs font-mono">
Участник: {participant.peerId.slice(0, 6)}
</span>
</div>
);
}Пользователь совершает видеозвонок со смартфона и выходит из зоны действия офисного Wi-Fi, переключаясь на сотовую сеть LTE. Видео мгновенно замирает, а статус RTCPeerConnection переходит в 'disconnected', затем в 'failed'. Каков правильный архитектурный алгоритм восстановления сессии без перезагрузки страницы?
Монтирование локального стрима в тег <video> без атрибута muted приводит к тому, что микрофон улавливает собственный голос из динамиков и бесконечно усиливает его, вызывая оглушительный высокочастотный вой.
Решение: Локальный элемент видео обязан всегда иметь жесткий атрибут muted={true}.
Удаление компонента из дерева React без явного вызова track.stop() на каждом элементе stream.getTracks() оставляет светодиод веб-камеры включенным, удерживая блокировку системного устройства операционной системы.
Решение: В функции очистки useEffect гарантированно обходите все треки: stream.getTracks().forEach(t => t.stop()).
Асинхронный вызов захвата экрана внутри useEffect или таймера setTimeout немедленно отклоняется браузером с ошибкой NotAllowedError: getDisplayMedia must be called from a user gesture.
Решение: Вызывайте navigator.mediaDevices.getDisplayMedia() строго в синхронном обработчике клика по кнопке интерфейса.
Хранение экземпляра соединения в useState или локальной переменной функции приводит к тому, что любой ререндер родительского компонента пересоздает объект соединения, разрывая установленный медиаканал.
Решение: Всегда храните инстанс RTCPeerConnection в useRef либо выносите в независимый сервисный слой за пределами дерева React.
MediaStreamTrack) управляются атомарно: переключение активности (enabled = false) заменяет разрушительный перезапуск аппаратных устройств.iceRestart: true) гарантирует бесшовное восстановление звонка при переходе мобильных клиентов между сетями Wi-Fi и LTE.muted={true}, а при размонтировании компонентов обязателен вызов track.stop() для освобождения физической веб-камеры.