Разбираем P2P mesh, SFU и MCU. В конце — полностью рабочий пример на WebRTC, который запускается за 10 минут.
Zoom, Meet, Teams — удобно, но:
Свой сервер конференций — это не так страшно, как кажется. Сегодня разберём три архитектуры и соберём первую.
WebRTC. Это браузерный стандарт для передачи аудио, видео и данных напрямую между браузерами. Он бесплатный, встроен во все современные браузеры и не требует плагинов. Вся разница между подходами — в том, как мы соединяем участников.
Каждый браузер соединяется напрямую с каждым другим браузером. Сервер нужен только чтобы «познакомить» участников (сигналинг) — дальше медиа идёт мимо него.
Когда использовать: личные звонки 1-на-1, маленькие команды до 4–5, прототипы, всё, где важна приватность и простота.
Появляется медиасервер. Каждый участник отправляет один поток на сервер, сервер раздаёт его всем остальным. Умный сервер может не отправлять вам видео того, кто молчит.
Готовые решения:
Сервер микширует все потоки в один и отдаёт каждому участнику одну картинку (как в классическом Zoom-е с сеткой).
Когда использовать: вебинары на сотни зрителей, подключение слабых клиентов, всё, где важна трансляция, а не интерактив.
| Критерий | P2P Mesh | SFU | MCU |
|---|---|---|---|
| Макс. участников | 4–6 | 20–50 | 100+ |
| Нагрузка на клиента | высокая | средняя | низкая |
| Нагрузка на сервер | почти нет | средняя | очень высокая |
| Сложность | ★☆☆ | ★★☆ | ★★★ |
| Задержка | низкая | средняя | высокая |
| Запись / транскрипция | нет | да | да |
| Стоимость хостинга | копейки | средняя | высокая |
Соберём видеоконференцию на 4 человека с шарингом экрана. Стек:
videochat/
├── package.json
├── server.js
└── public/
└── index.html
{
"name": "videochat",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.19.2",
"socket.io": "^4.7.5"
}
}
Сервер только «знакомит» участников. Он не видит ни видео, ни аудио.
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static(path.join(__dirname, 'public')));
// Комнаты: { roomId: Set<socketId> }
const rooms = new Map();
io.on('connection', (socket) => {
let currentRoom = null;
socket.on('join', (roomId) => {
currentRoom = roomId;
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
const room = rooms.get(roomId);
const others = [...room];
// Новому участнику — список тех, кто уже в комнате
socket.emit('existing-peers', others);
// Остальным — уведомление о новом
socket.to(roomId).emit('peer-joined', socket.id);
room.add(socket.id);
console.log(`[${roomId}] ${socket.id} вошёл. Всего: ${room.size}`);
});
// Пересылка сигналов (offer / answer / ICE) конкретному пиру
socket.on('signal', ({ to, data }) => {
io.to(to).emit('signal', { from: socket.id, data });
});
socket.on('disconnect', () => {
if (currentRoom && rooms.has(currentRoom)) {
const room = rooms.get(currentRoom);
room.delete(socket.id);
socket.to(currentRoom).emit('peer-left', socket.id);
if (room.size === 0) rooms.delete(currentRoom);
console.log(`[${currentRoom}] ${socket.id} вышел`);
}
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Сервер запущен: http://localhost:${PORT}`);
});
Весь клиент — один файл. Здесь и логика WebRTC, и UI, и шаринг экрана.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Видеоконференция</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a1a; color: #fff; }
#join { display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100vh; gap: 16px; }
#join input, #join button { padding: 12px 20px; font-size: 16px; border-radius: 8px; border: none; }
#join input { width: 260px; text-align: center; }
#join button { background: #0066cc; color: #fff; cursor: pointer; }
#join button:hover { background: #0052a3; }
#room { display: none; }
#videos { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px; padding: 12px; }
.video-wrap { position: relative; background: #000; border-radius: 10px; overflow: hidden;
aspect-ratio: 16/9; }
video { width: 100%; height: 100%; object-fit: cover; }
.video-wrap .label { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6);
padding: 4px 10px; border-radius: 6px; font-size: 12px; }
#controls { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; background: rgba(0,0,0,0.7); padding: 12px 16px;
border-radius: 12px; }
#controls button { padding: 10px 18px; border-radius: 8px; border: none; cursor: pointer;
background: #333; color: #fff; font-size: 14px; }
#controls button:hover { background: #444; }
#controls button.active { background: #0066cc; }
#controls button.danger { background: #c0392b; }
</style>
</head>
<body>
<div id="join">
<h1>Видеоконференция</h1>
<input id="roomInput" placeholder="Название комнаты" value="test">
<button id="joinBtn">Войти</button>
<p style="color:#888; font-size:14px">Открой эту страницу в 2–4 вкладках</p>
</div>
<div id="room">
<div id="videos"></div>
<div id="controls">
<button id="micBtn">🎤 Микрофон</button>
<button id="camBtn">📹 Камера</button>
<button id="screenBtn">🖥 Экран</button>
<button id="leaveBtn" class="danger">Выйти</button>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let localStream = null;
let screenStream = null;
let cameraTrack = null;
const peers = new Map(); // peerId -> RTCPeerConnection
const peerStreams = new Map(); // peerId -> MediaStream
const $ = (id) => document.getElementById(id);
const videosEl = $('videos');
// ---------- ICE-серверы ----------
// STUN бесплатный. Для продакшена нужен свой TURN (coturn).
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
// ---------- Вход в комнату ----------
$('joinBtn').onclick = async () => {
const roomId = $('roomInput').value.trim();
if (!roomId) return alert('Введите название комнаты');
try {
localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
} catch (e) {
alert('Нет доступа к камере/микрофону: ' + e.message);
return;
}
cameraTrack = localStream.getVideoTracks()[0];
$('join').style.display = 'none';
$('room').style.display = 'block';
addVideo('local', localStream, 'Вы');
socket.emit('join', roomId);
};
// ---------- Создание peer-соединения ----------
function createPeer(peerId, initiator) {
const pc = new RTCPeerConnection(rtcConfig);
peers.set(peerId, pc);
// Отправляем свои треки
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
// Принимаем чужие треки
pc.ontrack = (e) => {
let stream = peerStreams.get(peerId);
if (!stream) {
stream = new MediaStream();
peerStreams.set(peerId, stream);
addVideo(peerId, stream, 'Участник ' + peerId.slice(0, 4));
}
e.streams[0].getTracks().forEach(t => stream.addTrack(t));
};
// ICE-кандидаты — отправляем через сигнальный сервер
pc.onicecandidate = (e) => {
if (e.candidate) {
socket.emit('signal', { to: peerId, data: { candidate: e.candidate } });
}
};
pc.onconnectionstatechange = () => {
if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
removePeer(peerId);
}
};
if (initiator) {
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('signal', { to: peerId, data: { sdp: pc.localDescription } });
};
}
return pc;
}
// ---------- Сигналинг ----------
socket.on('existing-peers', async (others) => {
// Мы новые — создаём офферы каждому существующему
for (const peerId of others) {
createPeer(peerId, true);
}
});
socket.on('peer-joined', (peerId) => {
// Новый участник — ждём его оффер, но создаём pc заранее
createPeer(peerId, false);
});
socket.on('signal', async ({ from, data }) => {
let pc = peers.get(from);
if (!pc) pc = createPeer(from, false);
if (data.sdp) {
await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
if (data.sdp.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('signal', { to: from, data: { sdp: pc.localDescription } });
}
} else if (data.candidate) {
try { await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); }
catch (e) { console.warn('ICE error', e); }
}
});
socket.on('peer-left', (peerId) => removePeer(peerId));
// ---------- UI видео ----------
function addVideo(id, stream, label) {
if (document.getElementById('wrap-' + id)) return;
const wrap = document.createElement('div');
wrap.className = 'video-wrap';
wrap.id = 'wrap-' + id;
wrap.innerHTML = `<video autoplay playsinline ${id === 'local' ? 'muted' : ''}></video>
<div class="label">${label}</div>`;
wrap.querySelector('video').srcObject = stream;
videosEl.appendChild(wrap);
}
function removePeer(peerId) {
const pc = peers.get(peerId);
if (pc) { pc.close(); peers.delete(peerId); }
peerStreams.delete(peerId);
const wrap = document.getElementById('wrap-' + peerId);
if (wrap) wrap.remove();
}
// ---------- Кнопки ----------
$('micBtn').onclick = () => {
const track = localStream.getAudioTracks()[0];
track.enabled = !track.enabled;
$('micBtn').classList.toggle('active', !track.enabled);
$('micBtn').textContent = track.enabled ? '🎤 Микрофон' : '🔇 Микрофон выкл';
};
$('camBtn').onclick = () => {
const track = localStream.getVideoTracks()[0];
track.enabled = !track.enabled;
$('camBtn').classList.toggle('active', !track.enabled);
$('camBtn').textContent = track.enabled ? '📹 Камера' : '🚫 Камера выкл';
};
$('screenBtn').onclick = async () => {
// Если уже шарим — останавливаем
if (screenStream) {
screenStream.getTracks().forEach(t => t.stop());
screenStream = null;
await replaceVideoTrack(cameraTrack);
$('screenBtn').classList.remove('active');
$('screenBtn').textContent = '🖥 Экран';
return;
}
try {
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const screenTrack = screenStream.getVideoTracks()[0];
await replaceVideoTrack(screenTrack);
// Если пользователь остановил шаринг из системного окна
screenTrack.onended = () => {
screenStream = null;
replaceVideoTrack(cameraTrack);
$('screenBtn').classList.remove('active');
$('screenBtn').textContent = '🖥 Экран';
};
$('screenBtn').classList.add('active');
$('screenBtn').textContent = '⏹ Остановить';
} catch (e) {
console.warn('Screen share отменён', e);
}
};
// Замена видеотрека во всех peer-соединениях без пересоздания
async function replaceVideoTrack(newTrack) {
// Локальное видео
const localVideo = document.querySelector('#wrap-local video');
if (localVideo) {
const newStream = new MediaStream([newTrack, ...localStream.getAudioTracks()]);
localVideo.srcObject = newStream;
}
// У всех пиров
for (const pc of peers.values()) {
const sender = pc.getSenders().find(s => s.track && s.track.kind === 'video');
if (sender) await sender.replaceTrack(newTrack);
}
}
$('leaveBtn').onclick = () => {
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (screenStream) screenStream.getTracks().forEach(t => t.stop());
socket.disconnect();
location.reload();
};
</script>
</body>
</html>
mkdir videochat && cd videochat
npm init -y
npm install express socket.io
# создай server.js и public/index.html из кода выше
npm start
Открой http://localhost:3000 в двух-четырёх вкладках (или на разных устройствах в одной сети), введи одну и ту же комнату — и вы в конференции.
localhost). Браузер не даст доступ к камере на HTTP-сайте. Поставь Nginx + Let's Encrypt — это 10 минут.
Когда перерастёшь mesh (а ты перерастёшь, если команда больше 4–5 человек), двигайся так:
coturn — TURN-сервер для обхода NAT. Без него половина звонков будет падать.