↩️ Назад

Категории

Свой сервер видеоконференций: три подхода и рабочий MVP

22.09.2026 | из категории: Web-разработка

Свой сервер видеоконференций: три подхода и рабочий MVP

Разбираем P2P mesh, SFU и MCU. В конце — полностью рабочий пример на WebRTC, который запускается за 10 минут.

Зачем вообще свой сервер

Zoom, Meet, Teams — удобно, но:

Свой сервер конференций — это не так страшно, как кажется. Сегодня разберём три архитектуры и соберём первую.

Ключевая идея. В основе всего — WebRTC. Это браузерный стандарт для передачи аудио, видео и данных напрямую между браузерами. Он бесплатный, встроен во все современные браузеры и не требует плагинов. Вся разница между подходами — в том, как мы соединяем участников.

Вариант 1: P2P Mesh просто

Как работает

Каждый браузер соединяется напрямую с каждым другим браузером. Сервер нужен только чтобы «познакомить» участников (сигналинг) — дальше медиа идёт мимо него.

Алиса ────── Боб │ ╲ ╱ │ │ ╲ ╱ │ │ ╳ │ │ ╱ ╲ │ │ ╱ ╲ │ Карл ────── Даша (сервер только для знакомства)

✓ Плюсы

  • Просто: ~300 строк, никаких медиасерверов
  • Дёшево: сервер держит только WebSocket
  • Приватно: медиа не идёт через ваш сервер
  • Низкая задержка при хорошей сети

✗ Минусы

  • N-1 потоков с каждого. 6 человек — 5 исходящих. Ноут воет.
  • Предел 4–6 человек
  • Плохо масштабируется: 10 человек = 90 соединений
  • Качество падает на слабых машинах

Когда использовать: личные звонки 1-на-1, маленькие команды до 4–5, прототипы, всё, где важна приватность и простота.

Вариант 2: SFU (Selective Forwarding Unit) средне

Как работает

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

Алиса ──┐ │ Боб ────┼──► [ SFU ] ──┬──► Алиса │ ├──► Боб Карл ───┤ ├──► Карл │ └──► Даша Даша ───┘

✓ Плюсы

  • Масштаб: 20–50 человек спокойно
  • Экономия канала: 1 исходящий поток вместо N-1
  • Умная маршрутизация: отключать видео у молчащих
  • Запись, транскрипция, трансляция — на сервере

✗ Минусы

  • Сложнее: нужен отдельный медиасервер
  • Дороже: сервер обрабатывает весь трафик
  • Задержка чуть выше, чем в P2P
  • Медиа идёт через ваш сервер

Готовые решения:

Вариант 3: MCU (Mixing Unit) сложно

Как работает

Сервер микширует все потоки в один и отдаёт каждому участнику одну картинку (как в классическом Zoom-е с сеткой).

Все ──► [ MCU микширует ] ──► одна общая картинка каждому

✓ Плюсы

  • Минимум нагрузки на клиента: 1 входящий поток
  • Работает на слабых устройствах: телефоны, старые ноуты
  • Идеально для трансляций (спикер + зал)

✗ Минусы

  • Огромная нагрузка на сервер: CPU жрёт микширование
  • Самый сложный в реализации
  • Потеря гибкости: нельзя выбрать раскладку у клиента
  • Высокая задержка

Когда использовать: вебинары на сотни зрителей, подключение слабых клиентов, всё, где важна трансляция, а не интерактив.

Сравнительная таблица

КритерийP2P MeshSFUMCU
Макс. участников4–620–50100+
Нагрузка на клиентавысокаясредняянизкая
Нагрузка на серверпочти нетсредняяочень высокая
Сложность★☆☆★★☆★★★
Задержканизкаясредняявысокая
Запись / транскрипциянетдада
Стоимость хостингакопейкисредняявысокая
Практический совет. Начинайте с P2P mesh — этого хватит для большинства маленьких команд. Когда упрётесь в потолок 4–6 человек, переходите на LiveKit. MCU в 2025 году нужен редко — только для вещания на сотни зрителей.

Рабочий MVP: P2P mesh за 300 строк

Соберём видеоконференцию на 4 человека с шарингом экрана. Стек:

1 Структура проекта

videochat/
├── package.json
├── server.js
└── public/
    └── index.html

2 package.json

{
  "name": "videochat",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.19.2",
    "socket.io": "^4.7.5"
  }
}

3 server.js — сигнальный сервер

Сервер только «знакомит» участников. Он не видит ни видео, ни аудио.

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}`);
});

4 public/index.html — клиент

Весь клиент — один файл. Здесь и логика 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>

5 Запуск

mkdir videochat && cd videochat
npm init -y
npm install express socket.io
# создай server.js и public/index.html из кода выше
npm start

Открой http://localhost:3000 в двух-четырёх вкладках (или на разных устройствах в одной сети), введи одну и ту же комнату — и вы в конференции.

Важно про HTTPS. В продакшене WebRTC работает только по HTTPS (кроме localhost). Браузер не даст доступ к камере на HTTP-сайте. Поставь Nginx + Let's Encrypt — это 10 минут.
Важно про TURN. В коде только публичные STUN-серверы от Google. Они работают в 70–80% случаев. За симметричным NAT (мобильный интернет, корпоративные сети) соединение не установится. Для продакшена обязательно поднимай свой coturn — иначе половина пользователей не подключится.

Что дальше

Когда перерастёшь mesh (а ты перерастёшь, если команда больше 4–5 человек), двигайся так:

  1. Шаг 1. Подними coturn — TURN-сервер для обхода NAT. Без него половина звонков будет падать.
  2. Шаг 2. Добавь LiveKit — это SFU, который снимает нагрузку с клиентов и открывает путь к 20+ участникам. Разворачивается через Docker Compose.
  3. Шаг 3. Прикрути Egress (запись), Whisper (транскрипция), чат и модерацию — всё это уже есть в экосистеме LiveKit.
  4. Шаг 4. Если нужна трансляция на сотни зрителей — смотри в сторону MCU или гибридных схем (SFU + стриминг через HLS/RTMP).
Итог. Свой сервер видеоконференций — это не rocket science. MVP на 4 человека собирается за вечер и работает на VPS за $5. Дальше — вопрос масштаба и хотелок.
Теги: #MCU #MVP #P2P_Mesh #SFU #видеоконференция #сервер_видеоконференций



Категории:

Категории

← Назад к списку

Посетителей сегодня: 0
о блоге | карта блога | 📡 Подписаться на RSS

© Digital Specialist | Не являемся сотрудниками Google, Яндекса и NASA