Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b63c24332 | |||
| cd5399f88a | |||
| 2be799b19d | |||
| 6b9d0b833e | |||
| f4e8f91839 | |||
| fb50c5d8ea | |||
| 826a0a391a | |||
| 4f5f336cd6 | |||
| 0441f4f72b | |||
| 9bc8d6174d |
12
.env.example
12
.env.example
@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
|
|||||||
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
||||||
TURN_EXTERNAL_IP=127.0.0.1
|
TURN_EXTERNAL_IP=127.0.0.1
|
||||||
|
|
||||||
|
# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто =
|
||||||
|
# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn
|
||||||
|
# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через
|
||||||
|
# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись
|
||||||
|
# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru —
|
||||||
|
# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от
|
||||||
|
# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по
|
||||||
|
# имени хоста, а Let's Encrypt выписывает сертификат на домен.
|
||||||
|
TURN_TLS_HOST=
|
||||||
|
|
||||||
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
|
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
|
||||||
# Домены, которые обслуживает nginx (через пробел, все — в server_name).
|
# Домены, которые обслуживает nginx (через пробел, все — в server_name).
|
||||||
NGINX_SERVER_NAMES=example.com www.example.com
|
NGINX_SERVER_NAMES=example.com www.example.com
|
||||||
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.21
|
VIDCONF_VERSION=0.0.24
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
67
CHANGELOG.md
67
CHANGELOG.md
@@ -3,6 +3,73 @@
|
|||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [0.0.24] — 2026-08-03
|
||||||
|
|
||||||
|
Три артефакта вёрстки, вылезающие за границы блоков (эмодзи-поповер чата,
|
||||||
|
заголовок брендовой панели, чат в Firefox) + аудит похожих мест.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- Заголовок «Ваша инфраструктура» на странице входа вылезал за край
|
||||||
|
брендовой панели на широком окне с узкой панелью (`.layout` — flex
|
||||||
|
42/58) — старый фикс уменьшал кегль только по ширине ОКНА (`@media`),
|
||||||
|
а не панели. Кегль `.brand-headline` теперь считается через container
|
||||||
|
query (`container-type: inline-size` + `cqw`) — тот же приём, что у
|
||||||
|
аватара участника в комнате. Тем же механизмом обрезались плашки
|
||||||
|
статистики («AI-саммари») — `.brand-stats` теперь переносит их на
|
||||||
|
мобильном и десктопе одинаково.
|
||||||
|
- Эмодзи-поповер в чате комнаты: последняя (5-я) колонка вылезала за
|
||||||
|
правый край поповера. Причина — гонка CSS-специфичности: правило
|
||||||
|
круглой кнопки «Отправить» (`.chat-input-row button`, 42×42px)
|
||||||
|
продолжало красить размер и кнопкам эмодзи внутри поповера (та же
|
||||||
|
гонка чинилась для цвета в 0.0.19, но не для размера). Добавлены явные
|
||||||
|
`width`/`height: auto` нужной специфичности + `max-width` на попап как
|
||||||
|
общая страховка.
|
||||||
|
- Чат комнаты вылезал за границы панели в Firefox: `.chat-messages`
|
||||||
|
(flex-колонка) без `min-height: 0` не сжималась в Firefox, `textarea`
|
||||||
|
поля ввода без `min-width: 0` упиралась в автоматическую минимальную
|
||||||
|
ширину (Firefox считает её от атрибута `cols`, жёстче Chrome/Safari).
|
||||||
|
|
||||||
|
## [0.0.23] — 2026-08-02
|
||||||
|
|
||||||
|
Документация по сайзингу под медиа-нагрузку + видимость TURN-аллокаций в логах.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- `docs/deploy/hardware-sizing.md` — таблица «профиль нагрузки → CPU/RAM/
|
||||||
|
полоса» для медиа (видеоконференции), на реальных боевых замерах
|
||||||
|
28.07 и 31.07.2026, с формулой для расчёта под свой сценарий.
|
||||||
|
|
||||||
|
### Исправлено
|
||||||
|
- coturn: включено verbose-логирование — дефолтный уровень не писал
|
||||||
|
построчно `ALLOCATE`/`CreatePermission`/`Refresh` даже при рабочем
|
||||||
|
relay-соединении (найдено на релизе 0.0.22 — звонок через TURN работал,
|
||||||
|
а `grep -ci allocate` по логам coturn показывал 0).
|
||||||
|
|
||||||
|
## [0.0.22] — 2026-08-02
|
||||||
|
|
||||||
|
TURN over TLS (5349) — для клиентов из сетей, где наружу открыт только 443.
|
||||||
|
|
||||||
|
### Добавлено
|
||||||
|
- coturn слушает TLS на `5349` (сертификат Let's Encrypt, обновляется
|
||||||
|
автоматически). Включается одним ключом `TURN_TLS_HOST` в `.env` (домен
|
||||||
|
сертификата, НЕ IP) — пусто оставляет прежнее поведение без единого следа
|
||||||
|
в рендеренных конфигах.
|
||||||
|
- LiveKit объявляет клиентам запись `protocol: tls` в `rtc.turn_servers`
|
||||||
|
(последней в списке, как самый дорогой фолбэк) — без анонса включённый
|
||||||
|
TLS был бы бесполезен: именно так уже случалось с обычным TURN до 0.0.14
|
||||||
|
(сервис работал healthy, но не обслужил ни одной аллокации).
|
||||||
|
- Новый init-контейнер `coturn-certs-init`: копирует fullchain/privkey из
|
||||||
|
`/etc/letsencrypt` в отдельный volume под правами `644` — coturn
|
||||||
|
(`nobody:nogroup`, без root-фазы в entrypoint) не может прочитать
|
||||||
|
оригинальный ключ (`root`, `0600`), а права на хосте ослаблять нельзя.
|
||||||
|
- Deploy-hook certbot дополнительно перекопирует сертификат и перезапускает
|
||||||
|
`coturn` при продлении — без этого TLS-TURN тихо остановился бы
|
||||||
|
обслуживать новые TLS-хендшейки примерно через 60 дней.
|
||||||
|
|
||||||
|
### Не сделано
|
||||||
|
- TURN over `443` — недостижимо без SNI-мультиплексора: порт уже занят
|
||||||
|
nginx (Docker port-publish), а coturn слушает в `network_mode: host` и не
|
||||||
|
может разделить с ним один и тот же сокет.
|
||||||
|
|
||||||
## [0.0.21] — 2026-08-02
|
## [0.0.21] — 2026-08-02
|
||||||
|
|
||||||
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
|
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ VidConf использует единый инсталлятор `install.sh` с
|
|||||||
```
|
```
|
||||||
|
|
||||||
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
|
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
|
||||||
|
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
|
||||||
|
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
|
||||||
|
|
||||||
### Docker Compose профили (низкоуровневый контроль)
|
### Docker Compose профили (низкоуровневый контроль)
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
# затрутся при следующем рендере.
|
# затрутся при следующем рендере.
|
||||||
|
|
||||||
listening-port=3478
|
listening-port=3478
|
||||||
# Установить 5349 в проде для TURN over TLS, если будут смонтированы
|
# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
|
||||||
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже).
|
# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
|
||||||
tls-listening-port=5349
|
tls-listening-port=5349
|
||||||
|
|
||||||
# Диапазон relay-портов для TURN-аллокаций.
|
# Диапазон relay-портов для TURN-аллокаций.
|
||||||
@@ -31,14 +31,30 @@ fingerprint
|
|||||||
# Без CLI/telnet admin-интерфейса в этой поставке.
|
# Без CLI/telnet admin-интерфейса в этой поставке.
|
||||||
no-cli
|
no-cli
|
||||||
|
|
||||||
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить
|
# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||||
# TURN over TLS на 443:
|
# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
|
||||||
# cert=/etc/coturn/certs/cert.pem
|
# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
|
||||||
# pkey=/etc/coturn/certs/key.pem
|
# Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком —
|
||||||
|
# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк.
|
||||||
|
# BEGIN-TLS-CERT
|
||||||
|
cert=/etc/coturn/certs/cert.pem
|
||||||
|
pkey=/etc/coturn/certs/key.pem
|
||||||
|
# END-TLS-CERT
|
||||||
|
|
||||||
log-file=stdout
|
log-file=stdout
|
||||||
simple-log
|
simple-log
|
||||||
|
|
||||||
|
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
|
||||||
|
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
|
||||||
|
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
|
||||||
|
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
|
||||||
|
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
|
||||||
|
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
|
||||||
|
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
|
||||||
|
# печатает по сессии: create/delete allocation, create permission, refresh —
|
||||||
|
# достаточно, чтобы дальше проверять относительно скромный вывод.
|
||||||
|
verbose
|
||||||
|
|
||||||
# Внешний IP сервера — обязателен для клиентов вне docker-сети
|
# Внешний IP сервера — обязателен для клиентов вне docker-сети
|
||||||
# (network_mode: host здесь не даёт coturn определить публичный IP
|
# (network_mode: host здесь не даёт coturn определить публичный IP
|
||||||
# автоматически). Для локальной разработки (без внешних участников)
|
# автоматически). Для локальной разработки (без внешних участников)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.21}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.24}
|
||||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
@@ -432,6 +432,49 @@ services:
|
|||||||
profiles: ["media"]
|
profiles: ["media"]
|
||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
|
|
||||||
|
# Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
|
||||||
|
# НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
|
||||||
|
# сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
|
||||||
|
# официального образа nginx, который стартует entrypoint от root и только
|
||||||
|
# nginx-воркеры позже понижают права по директиве в конфиге — см.
|
||||||
|
# deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
|
||||||
|
# сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
|
||||||
|
# никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
|
||||||
|
#
|
||||||
|
# Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
|
||||||
|
# в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
|
||||||
|
# /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
|
||||||
|
# fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
|
||||||
|
# оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
|
||||||
|
# открыта, чтобы её прочитал nobody:nogroup внутри coturn.
|
||||||
|
#
|
||||||
|
# Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
|
||||||
|
# сертификатов) — команда ниже просто ничего не копирует и завершается
|
||||||
|
# успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
|
||||||
|
# render-templates.sh — вторая половина того же переключателя).
|
||||||
|
coturn-certs-init:
|
||||||
|
image: busybox:1.36
|
||||||
|
command: >
|
||||||
|
sh -c '
|
||||||
|
SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
|
||||||
|
if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
|
||||||
|
cp "$$SRC/fullchain.pem" /certs/cert.pem;
|
||||||
|
cp "$$SRC/privkey.pem" /certs/key.pem;
|
||||||
|
chmod 644 /certs/cert.pem /certs/key.pem;
|
||||||
|
echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
|
||||||
|
else
|
||||||
|
echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
environment:
|
||||||
|
NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
|
||||||
|
volumes:
|
||||||
|
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
|
- coturn-certs:/certs
|
||||||
|
restart: "no"
|
||||||
|
profiles: ["media"]
|
||||||
|
logging: *default-logging
|
||||||
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -444,6 +487,10 @@ services:
|
|||||||
# (deploy/render-templates.sh, вызывается install.sh).
|
# (deploy/render-templates.sh, вызывается install.sh).
|
||||||
volumes:
|
volumes:
|
||||||
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||||
|
- coturn-certs:/etc/coturn/certs:ro
|
||||||
|
depends_on:
|
||||||
|
coturn-certs-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
network_mode: host
|
network_mode: host
|
||||||
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
||||||
# curl/wget, поэтому проверка процесса по имени не работает
|
# curl/wget, поэтому проверка процесса по имени не работает
|
||||||
@@ -887,6 +934,11 @@ volumes:
|
|||||||
# Загруженные пользователями файлы (аватары) — общий том между
|
# Загруженные пользователями файлы (аватары) — общий том между
|
||||||
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
|
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
|
||||||
media:
|
media:
|
||||||
|
# Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
|
||||||
|
# (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
|
||||||
|
# coturn-certs-init выше. Обновляется при каждом перезапуске
|
||||||
|
# coturn-certs-init (deploy-hook certbot делает это при продлении).
|
||||||
|
coturn-certs:
|
||||||
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
|
||||||
prometheus_data:
|
prometheus_data:
|
||||||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||||||
|
|||||||
@@ -54,10 +54,24 @@ rtc:
|
|||||||
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||||
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||||
#
|
#
|
||||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
|
# UDP и TCP на 3478 — оба порта уже открыты в ufw.
|
||||||
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
|
#
|
||||||
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
|
# TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||||
# прежде чем перейти к рабочему кандидату.
|
# render-templates.sh) — до тех пор блок между маркерами вырезается
|
||||||
|
# целиком, и клиент его не увидит вовсе. Это осознанно: анонс
|
||||||
|
# неработающего `turns:` (без смонтированных в coturn сертификатов)
|
||||||
|
# заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти
|
||||||
|
# к рабочему кандидату — именно так это и стояло здесь до включения TLS.
|
||||||
|
#
|
||||||
|
# Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp
|
||||||
|
# выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста,
|
||||||
|
# а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host
|
||||||
|
# TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё
|
||||||
|
# один вариант «coturn healthy, но relay не работает».
|
||||||
|
#
|
||||||
|
# TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку,
|
||||||
|
# а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не
|
||||||
|
# выбираться первой.
|
||||||
turn_servers:
|
turn_servers:
|
||||||
- host: ${TURN_EXTERNAL_IP}
|
- host: ${TURN_EXTERNAL_IP}
|
||||||
port: 3478
|
port: 3478
|
||||||
@@ -69,6 +83,13 @@ rtc:
|
|||||||
protocol: tcp
|
protocol: tcp
|
||||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
ttl: 14400
|
ttl: 14400
|
||||||
|
# BEGIN-TLS-TURN
|
||||||
|
- host: ${TURN_TLS_HOST}
|
||||||
|
port: 5349
|
||||||
|
protocol: tls
|
||||||
|
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||||
|
ttl: 14400
|
||||||
|
# END-TLS-TURN
|
||||||
|
|
||||||
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
|
||||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||||
|
|||||||
@@ -23,13 +23,27 @@ fi
|
|||||||
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
||||||
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
||||||
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
||||||
|
#
|
||||||
|
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
|
||||||
|
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
|
||||||
|
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
|
||||||
|
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
|
||||||
|
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
|
||||||
|
# существующих .env).
|
||||||
env_var() {
|
env_var() {
|
||||||
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2-
|
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
|
||||||
}
|
}
|
||||||
|
|
||||||
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
|
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
|
||||||
TURN_REALM="$(env_var TURN_REALM)"
|
TURN_REALM="$(env_var TURN_REALM)"
|
||||||
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
|
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
|
||||||
|
# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём
|
||||||
|
# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS
|
||||||
|
# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) —
|
||||||
|
# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST
|
||||||
|
# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию
|
||||||
|
# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template).
|
||||||
|
TURN_TLS_HOST="$(env_var TURN_TLS_HOST)"
|
||||||
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
|
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
|
||||||
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
||||||
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
||||||
@@ -43,17 +57,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
|
|||||||
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
||||||
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
|
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
|
||||||
|
|
||||||
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
|
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
|
||||||
|
|
||||||
|
# Вырезает блок между парой маркеров-комментариев (не только их самих), если
|
||||||
|
# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов,
|
||||||
|
# а не просто закомментированные "на будущее" строки.
|
||||||
|
strip_tls_block_if_disabled() {
|
||||||
|
local file="$1" begin_marker="$2" end_marker="$3"
|
||||||
|
if [ -z "$TURN_TLS_HOST" ]; then
|
||||||
|
sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak"
|
||||||
|
else
|
||||||
|
sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
|
||||||
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
|
||||||
echo "[render] deploy/coturn/turnserver.conf готов"
|
strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT"
|
||||||
|
echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")"
|
||||||
|
|
||||||
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
|
||||||
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||||
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
|
# обязан совпадать с `static-auth-secret` в turnserver.conf выше.
|
||||||
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \
|
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \
|
||||||
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
|
||||||
|
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
|
||||||
echo "[render] deploy/livekit/livekit.yaml готов"
|
echo "[render] deploy/livekit/livekit.yaml готов"
|
||||||
|
|
||||||
envsubst '${REDIS_PASSWORD}' \
|
envsubst '${REDIS_PASSWORD}' \
|
||||||
|
|||||||
@@ -237,7 +237,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
|
|||||||
```bash
|
```bash
|
||||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
|
||||||
|
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
|
||||||
|
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
|
||||||
|
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
|
||||||
|
# ни для того, ни для другого — нужен restart.
|
||||||
docker restart vidconf-nginx-1
|
docker restart vidconf-nginx-1
|
||||||
|
|
||||||
|
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
|
||||||
|
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
|
||||||
|
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
|
||||||
|
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
|
||||||
|
cd /opt/vidconf || exit 1
|
||||||
|
if grep -qE '^TURN_TLS_HOST=.+' .env; then
|
||||||
|
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
|
||||||
|
docker restart vidconf-coturn-1
|
||||||
|
fi
|
||||||
EOF
|
EOF
|
||||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||||
|
|
||||||
@@ -277,7 +292,8 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
|
|||||||
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
||||||
|
|
||||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
||||||
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
|
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
|
||||||
|
# если включили TURN over TLS — раздел 8)
|
||||||
ss -ltnp
|
ss -ltnp
|
||||||
|
|
||||||
# 3. Redis требует пароль (НЕ должен пускать без него)
|
# 3. Redis требует пароль (НЕ должен пускать без него)
|
||||||
@@ -358,15 +374,107 @@ state: connected`, но собеседник не видит видео/не с
|
|||||||
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
|
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
|
||||||
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||||
смотрите ufw и `TURN_EXTERNAL_IP`.
|
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||||
|
⚠️ **Эта проверка работает только с `verbose` в
|
||||||
|
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
|
||||||
|
этого флага coturn пишет в лог только служебные строки старта
|
||||||
|
(листенеры, `Total auth threads`) и НИКОГДА не логирует
|
||||||
|
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
|
||||||
|
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
|
||||||
|
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
|
||||||
|
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
|
||||||
|
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
|
||||||
|
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
|
||||||
|
и `verbose` в нём нет — добавьте флаг, перерендерите
|
||||||
|
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
|
||||||
|
доверять этой проверке.
|
||||||
|
|
||||||
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
|
### TURN over TLS (порт 5349)
|
||||||
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
|
|
||||||
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
|
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
|
||||||
`deploy/coturn/turnserver.conf.template`, добавить volume с
|
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
|
||||||
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
|
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
|
||||||
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
|
|
||||||
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
|
**443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
|
||||||
клиента ждать таймаута перед переходом к рабочему кандидату.
|
занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
|
||||||
|
`network_mode: host` — оба не могут забрать один и тот же порт без
|
||||||
|
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
|
||||||
|
сложная система; в этом проекте её нет, и заводить её только ради 443 не
|
||||||
|
оправдано, пока 5349 проходит через те же firewall, что и 443.
|
||||||
|
|
||||||
|
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
|
||||||
|
# NGINX_CERT_NAME:
|
||||||
|
TURN_TLS_HOST=vidconf.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
|
||||||
|
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
|
||||||
|
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
|
||||||
|
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
|
||||||
|
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
|
||||||
|
|
||||||
|
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
|
||||||
|
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
|
||||||
|
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
|
||||||
|
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
|
||||||
|
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
|
||||||
|
один вариант «coturn healthy, а relay не работает», только на новом порту.
|
||||||
|
|
||||||
|
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
|
||||||
|
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
|
||||||
|
выполняется от root) физически не может прочитать
|
||||||
|
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
|
||||||
|
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
|
||||||
|
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
|
||||||
|
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
|
||||||
|
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
|
||||||
|
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
|
||||||
|
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
|
||||||
|
отработает.
|
||||||
|
|
||||||
|
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
|
||||||
|
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
|
||||||
|
копии, которые перечитываются только при (пере)старте контейнера — reload
|
||||||
|
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
|
||||||
|
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
|
||||||
|
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
|
||||||
|
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
|
||||||
|
тихо остановится обслуживать новые TLS-хендшейки примерно через два
|
||||||
|
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
|
||||||
|
действия, и клиенты начнут получать ошибку валидации сертификата при
|
||||||
|
попытке TLS-хендшейка.
|
||||||
|
|
||||||
|
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
|
||||||
|
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
|
||||||
|
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
|
||||||
|
непредсказуемое время суток).
|
||||||
|
|
||||||
|
Порядок включения:
|
||||||
|
|
||||||
|
1. `TURN_TLS_HOST=<домен>` в `.env`.
|
||||||
|
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
|
||||||
|
`livekit.yaml` с TLS-блоками).
|
||||||
|
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
|
||||||
|
4. `docker compose … up -d --force-recreate coturn-certs-init coturn` —
|
||||||
|
пересоздать (не просто restart: новый volume/depends_on).
|
||||||
|
5. Проверить, что TLS реально отвечает:
|
||||||
|
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
|
||||||
|
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
|
||||||
|
соединения.
|
||||||
|
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
|
||||||
|
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
|
||||||
|
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
|
||||||
|
`certbot renew --dry-run`.
|
||||||
|
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
|
||||||
|
transport policy `relay` в браузере), и убедиться, что аллокации в
|
||||||
|
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
|
||||||
|
обычный TURN на 3478 продолжает работать для остальных клиентов
|
||||||
|
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
|
||||||
|
отдельной строкой `ALLOCATE processed, success` — можно отличить
|
||||||
|
TLS-сессию от обычной по времени и по тому, что порт входящего
|
||||||
|
соединения — 5349).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
|
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
|
||||||
|
|
||||||
|
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
|
||||||
|
> и готовой таблицы «профиль нагрузки → железо» см.
|
||||||
|
> [hardware-sizing.md](hardware-sizing.md).
|
||||||
|
|
||||||
Оценивает,
|
Оценивает,
|
||||||
сколько одновременных издателей аудио+видео и подписчиков выдерживает
|
сколько одновременных издателей аудио+видео и подписчиков выдерживает
|
||||||
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
|
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
|
||||||
|
|||||||
@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
|
|||||||
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
|
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
|
||||||
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
|
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
|
||||||
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
|
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
|
||||||
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS
|
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
|
||||||
|
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
|
||||||
|
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода
|
||||||
|
|||||||
243
docs/deploy/hardware-sizing.md
Normal file
243
docs/deploy/hardware-sizing.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Профиль нагрузки → рекомендуемое железо (медиа)
|
||||||
|
|
||||||
|
Отвечает на вопрос «сколько CPU, RAM и полосы нужно под ожидаемую видео-нагрузку»
|
||||||
|
для тех, кто разворачивает VidConf у себя. Разговор именно про **медиа**
|
||||||
|
(конференции, LiveKit SFU) — сайзинг под AI (транскрибация/суммаризация) описан
|
||||||
|
отдельно: [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
|
||||||
|
[ADR-004](../architecture/adr/004-ai-tier-matrix.md). Базовый пресет инсталлятора
|
||||||
|
без AI (`4 vCPU / 8 ГБ RAM / 40 ГБ диска`) рассчитан именно на медиа-нагрузку —
|
||||||
|
этот документ объясняет, какую конференцию такое железо реально держит и когда
|
||||||
|
его уже мало.
|
||||||
|
|
||||||
|
От синтетического нагрузочного теста [capacity.md](capacity.md) (SFU на dev-Mac,
|
||||||
|
формула по CPU) этот документ отличается источником цифр: здесь — **измерения на
|
||||||
|
боевом сервере с реальными людьми**, не эмуляция.
|
||||||
|
|
||||||
|
⚠️ Все числа ниже — **ориентировочные**, при указанных допущениях. Реальная
|
||||||
|
нагрузка зависит от поведения людей: сколько включат камеру, будет ли демонстрация
|
||||||
|
экрана, как долго говорят несколько человек одновременно. Используйте таблицу как
|
||||||
|
отправную точку для выбора железа, не как гарантию.
|
||||||
|
|
||||||
|
## Точка привязки к реальности
|
||||||
|
|
||||||
|
Единственные цифры ниже, которые не расчёт, а прямое измерение на боевом сервере
|
||||||
|
(`4 CPU, 8 ГБ RAM, Ubuntu 24.04`, тот же узел, где сейчас работает `vidconf.ru`):
|
||||||
|
|
||||||
|
| Дата | Версия | Участников | Камер | Подписок на видео | Исходящий трафик LiveKit | CPU LiveKit |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 28.07.2026 | 0.0.11, **до** `adaptiveStream` | 19 | 17 | ~306 | 140–169 Мбит/с устойчиво (пик 240) | 1.62 ядра из 4 |
|
||||||
|
| 31.07.2026 | 0.0.13, **после** `adaptiveStream`+`dynacast` | 30 | 27 | 783 | **70.6 Мбит/с** | 1.83 ядра из 4 |
|
||||||
|
|
||||||
|
Второе измерение и есть якорь для формулы ниже — оно снято на конфигурации,
|
||||||
|
максимально близкой к дефолтной (пагинация сетки участников, `adaptiveStream`,
|
||||||
|
`dynacast`), без ручной настройки под тест.
|
||||||
|
|
||||||
|
**Почему не пиковые 240 Мбит/с.** Пик — кратковременный всплеск, а не режим, в
|
||||||
|
котором сервер работал устойчиво; 1.62 ядра CPU намерены именно под устойчивые
|
||||||
|
140–169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
|
||||||
|
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
|
||||||
|
такому коэффициенту недооценит нужное железо.
|
||||||
|
|
||||||
|
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
|
||||||
|
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
|
||||||
|
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
|
||||||
|
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
|
||||||
|
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
|
||||||
|
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
|
||||||
|
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
|
||||||
|
разы, см. следующий раздел.
|
||||||
|
|
||||||
|
## Почему нельзя считать «все видят всех»
|
||||||
|
|
||||||
|
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
|
||||||
|
`N × (N−1) × битрейт` (каждый участник получает поток от каждого) — при 80
|
||||||
|
участниках даёт единицы **гигабит в секунду**: недостижимо на одном сервере и не
|
||||||
|
имеет отношения к тому, что видит пользователь на экране.
|
||||||
|
|
||||||
|
Реальная модель другая: клиент подписан не на всех, а на **видимые плитки**, и
|
||||||
|
получает под каждую подписку битрейт по фактическому размеру плитки — благодаря
|
||||||
|
пагинации сетки (с релиза 0.0.11) и `adaptiveStream`/`dynacast` (с 0.0.13).
|
||||||
|
Отсюда рабочая формула:
|
||||||
|
|
||||||
|
```
|
||||||
|
подписок на видео = камер × (участников − 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Она подтверждена обоими измерениями выше **точно**: 17 × 18 = 306, 27 × 29 = 783.
|
||||||
|
Это формула для типичного «видят всех камер» размещения (сетка без ручного
|
||||||
|
скрытия участников) — при включённом лимите плиток (см. ниже) число подписок не
|
||||||
|
растёт дальше лимита, даже если камер больше.
|
||||||
|
|
||||||
|
**Насколько наивная формула хуже.** Гипотетически, если бы все 75 участников
|
||||||
|
большого собрания (профиль ниже) были источником видео и каждый получал полный
|
||||||
|
поток от каждого — `75 × 74 × 1.5 Мбит/с` (типичный битрейт публикации без
|
||||||
|
адаптации) — это **9.48 Гбит/с**: недостижимо ни на одном разумном сервере.
|
||||||
|
Модель выше на сопоставимом масштабе (профиль «Большое собрание», 15 камер из
|
||||||
|
75) даёт около 182 Мбит/с — **в 50 с лишним раз меньше**. Разница — не оптимизация
|
||||||
|
в мелочах, а другая по порядку величины задача, и именно поэтому SFU вообще
|
||||||
|
годится для конференций на десятки участников.
|
||||||
|
|
||||||
|
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
|
||||||
|
2–5 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
|
||||||
|
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
|
||||||
|
|
||||||
|
## Формула
|
||||||
|
|
||||||
|
```
|
||||||
|
1. подписок = камер × (участников − 1)
|
||||||
|
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
|
||||||
|
|
||||||
|
2. видео = подписок × битрейт_на_подписку
|
||||||
|
|
||||||
|
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
|
||||||
|
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
|
||||||
|
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
|
||||||
|
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
|
||||||
|
(часть подписок физически не на экране — почти не потребляет полосы)
|
||||||
|
|
||||||
|
3. аудио = активных_микрофонов × участников × 40 кбит/с
|
||||||
|
|
||||||
|
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
|
||||||
|
|
||||||
|
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
|
||||||
|
+ 1–2 ядра на остальной стек (backend, БД, Redis, coturn, nginx, ОС)
|
||||||
|
|
||||||
|
6. RAM ≈ 4 ГБ база (без AI-профилей) — на этом масштабе RAM не была узким
|
||||||
|
местом ни на одном реальном или синтетическом тесте, планировать по CPU и полосе
|
||||||
|
```
|
||||||
|
|
||||||
|
**Откуда коэффициенты.**
|
||||||
|
|
||||||
|
- 450 / 150 кбит/с — измерение одного трека клиентом в крупной и мелкой плитке
|
||||||
|
(релиз 0.0.13), округлено вверх от 453 и 147 для запаса.
|
||||||
|
- 100 кбит/с — обратный расчёт по якорному замеру 31.07.2026:
|
||||||
|
70.6 Мбит/с ÷ 783 подписки ≈ 90 кбит/с в среднем, округлено вверх. Число ниже,
|
||||||
|
чем «мелкая плитка» (147), потому что в комнате на 30 участников часть из 783
|
||||||
|
подписок физически не помещалась на текущую страницу сетки — `adaptiveStream`
|
||||||
|
почти обнулил их битрейт, а среднее по всем подпискам это отражает.
|
||||||
|
- 90 Мбит/с на ядро — из 28.07.2026: 140–169 Мбит/с устойчиво ÷ 1.62 ядра =
|
||||||
|
86–104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
|
||||||
|
ядер).
|
||||||
|
- Минимум 2 ядра и запас 1–2 ядра на остальной стек — эмпирический пол: на
|
||||||
|
обоих реальных замерах LiveKit не опускался ниже 1.6 ядра независимо от
|
||||||
|
трафика, а весь остальной стек (backend, Postgres, Redis, coturn, nginx) на
|
||||||
|
боевом сервере устойчиво укладывается в разницу между занятым LiveKit и 4
|
||||||
|
доступными ядрами.
|
||||||
|
|
||||||
|
⚠️ **Формула по полосе — не единственная граница.** Между двумя замерами
|
||||||
|
подписок стало в 2.6 раза больше, трафик упал вдвое, а CPU почти не изменился
|
||||||
|
(1.62 → 1.83 ядра) — то есть процессор тратится в первую очередь на
|
||||||
|
обработку пакетов/подписок, а не на байты. На сценариях с очень большим числом
|
||||||
|
мелких подписок (много участников, лимит плиток не выставлен) реальный CPU
|
||||||
|
может обогнать то, что предсказывает формула по полосе быстрее, чем ожидается
|
||||||
|
— держите эмпирический пол (2 ядра LiveKit минимум под любую активную
|
||||||
|
конференцию) и не полагайтесь только на деление на 90.
|
||||||
|
|
||||||
|
## Профили нагрузки
|
||||||
|
|
||||||
|
Все профили — при `adaptiveStream`+`dynacast` (по умолчанию с 0.0.13) и без
|
||||||
|
AI-профилей (`transcribe`/`llm`). Допущения по камерам/микрофонам — решение,
|
||||||
|
не измерение; подставьте свои, если знаете реальный сценарий.
|
||||||
|
|
||||||
|
| Профиль | Сценарий | Камер | Подписок | Полоса (видео+аудио) | CPU (LiveKit) | RAM | Узкое место |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Малая команда | 10 параллельных созвонов по 5 чел., 60% с камерой, 2 микрофона в каждом | 3×10 | 12×10=120 | ~58 Мбит/с | 2 ядра | 4 ГБ | нет — запас большой |
|
||||||
|
| Совещание | 1 конференция × 25 чел., 70% с камерой, 4 микрофона | 18 | 432 | ~69 Мбит/с | 2 ядра | 4 ГБ | полоса — близко к нашему якорю (70.6 Мбит/с на 30 чел.) |
|
||||||
|
| Большое собрание | 1 конференция × 75 чел., 20% с камерой (камер меньше лимита плиток), 5 микрофонов | 15 | 1110 | ~182 Мбит/с | 3 ядра | 4–6 ГБ | полоса и её цена у хостера |
|
||||||
|
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
|
||||||
|
|
||||||
|
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
|
||||||
|
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
|
||||||
|
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
|
||||||
|
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
|
||||||
|
LiveKit плюс 1–2 под остальной стек означают, что 4 vCPU становятся тесными.
|
||||||
|
|
||||||
|
### Когда профиль недостижим — и как его спасти
|
||||||
|
|
||||||
|
«Большое собрание, все 75 человек с камерой» без ограничений: подписок
|
||||||
|
75 × 74 = 5550, полоса по коэффициенту пагинации (100 кбит/с) — уже **~555
|
||||||
|
Мбит/с** только видео, плюс аудио. Такой канал недостижим на типичном железе
|
||||||
|
и его аренде — это не вопрос выбора сервера мощнее, это упирается в канал
|
||||||
|
и его стоимость у хостера.
|
||||||
|
|
||||||
|
Спасает **лимит плиток на экране** (настройка инстанса «Максимум плиток на
|
||||||
|
странице», 25/16/9/4, с релиза 0.0.21): он ограничивает число подписок сверху
|
||||||
|
независимо от числа камер — `min(камер, лимит) × (участников − 1)`. При лимите
|
||||||
|
16 и том же собрании: 16 × 74 = 1184 подписки × 150 кбит/с (все 16 видны
|
||||||
|
одновременно, без пагинации) ≈ **178 Мбит/с** только видео — втрое меньше, чем
|
||||||
|
без лимита, но всё ещё требует железа заметно больше базового пресета (3+ ядра
|
||||||
|
LiveKit по формуле). Лимит делает профиль реалистичным, не дешёвым. Тот же
|
||||||
|
эффект даёт «Потолок качества публикации» (720p/360p/180p, тот же релиз) —
|
||||||
|
режет битрейт публикации у источника, а не только у подписчика.
|
||||||
|
|
||||||
|
Если и это не помогает — речь уже не про один сервер, а про горизонтальное
|
||||||
|
масштабирование LiveKit-кластера, вне рамок этого документа.
|
||||||
|
|
||||||
|
## Входящая полоса и канал клиента — отдельная история
|
||||||
|
|
||||||
|
Таблица выше — **исходящая** полоса сервера (каждому подписчику отдельная
|
||||||
|
копия), она и есть главный параметр: растёт с числом участников и подписок.
|
||||||
|
|
||||||
|
**Входящая** полоса (от клиентов к серверу) на порядок меньше: она равна сумме
|
||||||
|
битрейтов публикуемых потоков — `камер × ~0.3–1.5 Мбит/с` (зависит от «Потолка
|
||||||
|
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
|
||||||
|
собрания на 75 человек с 15 камерами это 4.5–22.5 Мбит/с входящих — заметно
|
||||||
|
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
|
||||||
|
|
||||||
|
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
|
||||||
|
участников совещания, может упереться в свой исходящий/входящий канал раньше,
|
||||||
|
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
|
||||||
|
сетевой инфраструктуры на стороне участников.
|
||||||
|
|
||||||
|
## Оговорка про канал и его стоимость
|
||||||
|
|
||||||
|
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
|
||||||
|
оказывается почти всегда не CPU — оба реальных замера показали комфортный
|
||||||
|
запас (1.6–1.83 ядра из 4) — а **исходящая полоса и её стоимость у хостера**.
|
||||||
|
Большинство тарифов VPS считают трафик либо лимитом с доплатой за перебор,
|
||||||
|
либо по 95-му перцентилю канала; при планировании крупных конференций
|
||||||
|
сверяйтесь с тарифом хостера на трафик/канал, а не только с числом ядер и
|
||||||
|
объёмом RAM.
|
||||||
|
|
||||||
|
## Как посчитать под свой сценарий
|
||||||
|
|
||||||
|
1. Оцените участников (N), долю с камерой, число одновременно активных
|
||||||
|
микрофонов.
|
||||||
|
2. `подписок = камер × (N − 1)`; если планируете включить лимит плиток —
|
||||||
|
`min(камер, лимит) × (N − 1)`.
|
||||||
|
3. Выберите битрейт на подписку по разделу «Формула» (450 / 150 / 100 кбит/с)
|
||||||
|
в зависимости от того, помещаются ли все камеры на одну страницу.
|
||||||
|
4. `видео = подписок × битрейт`, `аудио = микрофонов × N × 40 кбит/с`,
|
||||||
|
`полоса = видео + аудио`.
|
||||||
|
5. `ядер CPU ≈ max(2, ⌈полоса ÷ 90⌉) + 1–2` на остальной стек.
|
||||||
|
6. RAM — 4 ГБ база, не растёт заметно с этим масштабом участников (растёт с
|
||||||
|
выбранным уровнем AI, см. [ADR-004](../architecture/adr/004-ai-tier-matrix.md),
|
||||||
|
если он используется).
|
||||||
|
7. Сверьте полосу с тарифом хостера на трафик/канал — часто это упрётся раньше
|
||||||
|
железа.
|
||||||
|
|
||||||
|
## Что может измениться
|
||||||
|
|
||||||
|
⚠️ Коэффициент «90 Мбит/с на ядро» и оба якорных замера сняты **до** перевода
|
||||||
|
LiveKit с диапазона UDP-портов на `rtc.udp_port` (задача «Сеть LiveKit», релиз
|
||||||
|
0.0.20, задеплоено 02.08.2026) — до этой правки медиатрафик на хосте шёл через
|
||||||
|
процессы `docker-proxy` (userland-прокси Docker). Сама оптимизация меняет путь
|
||||||
|
пакетов на хосте, а не логику LiveKit, поэтому полоса из таблиц, скорее всего,
|
||||||
|
не изменится, а запас по CPU/сети хоста на практике может оказаться больше
|
||||||
|
указанного здесь. Новый нагрузочный тест с реальными участниками после этой
|
||||||
|
оптимизации пока не проводился — числа в этом документе консервативны и не
|
||||||
|
переоценивают требуемое железо, но при появлении нового замера на текущей
|
||||||
|
сети коэффициенты стоит пересчитать.
|
||||||
|
|
||||||
|
## Смотрите также
|
||||||
|
|
||||||
|
- [capacity.md](capacity.md) — синтетический нагрузочный тест SFU (`lk load-test`
|
||||||
|
на dev-Mac) и формула по CPU для верхней оценки ёмкости узла; используйте вместе
|
||||||
|
с этим документом, если нужна методика для собственного повторного теста.
|
||||||
|
- [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
|
||||||
|
[ADR-004](../architecture/adr/004-ai-tier-matrix.md) — сайзинг под AI
|
||||||
|
(транскрибация/суммаризация), отдельно от медиа.
|
||||||
|
- [monitoring.md](monitoring.md) — как снять реальные цифры со своего сервера
|
||||||
|
(CPU/RAM/сеть по контейнерам, Grafana).
|
||||||
|
- [DEPLOYMENT.md](DEPLOYMENT.md) — TURN, порты, что открыть в файрволе под
|
||||||
|
медиа-трафик.
|
||||||
@@ -16,6 +16,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-border);
|
||||||
|
/* Запрос по ширине САМОЙ панели, а не окна: `.layout` — flex 42/58, и на
|
||||||
|
широком окне с узкой панелью `vw` (см. .brand-headline) не отражает
|
||||||
|
реальную доступную ширину — тот же паттерн, что у `.room-tile-avatar`
|
||||||
|
(container-type:size + cqmin, room.css) для аватара участника. */
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
.brand-panel::after {
|
.brand-panel::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -41,6 +46,12 @@
|
|||||||
.brand-headline {
|
.brand-headline {
|
||||||
font: var(--text-display-lg);
|
font: var(--text-display-lg);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
/* `cqw` — от ширины `.brand-panel` (её `container-type: inline-size` выше),
|
||||||
|
а не окна: длинное слово «инфраструктура» иначе не помещается именно
|
||||||
|
тогда, когда окно широкое, а панель (42% от него) — узкая. Раньше кегль
|
||||||
|
уменьшался только в @media по ширине ОКНА (узкие экраны) — не спасало
|
||||||
|
от этого случая. */
|
||||||
|
font-size: clamp(20px, 8cqw, 34px);
|
||||||
color: var(--color-ink-700);
|
color: var(--color-ink-700);
|
||||||
margin: 0 0 var(--space-4);
|
margin: 0 0 var(--space-4);
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
@@ -51,7 +62,11 @@
|
|||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.brand-stats { display: flex; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); }
|
/* `flex-wrap` не только на мобильном медиа-запросе (см. ниже) — та же
|
||||||
|
природа бага, что у заголовка: узкая ПАНЕЛЬ (а не узкое окно) не даёт
|
||||||
|
двум плашкам поместиться в ряд, а `overflow:hidden` у `.brand-panel`
|
||||||
|
обрезал вторую вместо переноса. */
|
||||||
|
.brand-stats { display: flex; flex-wrap: wrap; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); }
|
||||||
.stat-glass {
|
.stat-glass {
|
||||||
background: var(--color-surface-glass);
|
background: var(--color-surface-glass);
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
@@ -248,10 +263,10 @@
|
|||||||
right: -90px;
|
right: -90px;
|
||||||
bottom: -90px;
|
bottom: -90px;
|
||||||
}
|
}
|
||||||
/* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за
|
/* Кегль теперь считает `.brand-headline` сама (cqw от ширины панели, см.
|
||||||
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden,
|
базовое правило) — здесь снимаем только `max-width:460px`: в сложенной
|
||||||
обрезающий текст без переноса вместо уменьшения кегля). */
|
колонкой раскладке панель может стать шире 460px, а дизайн этого хочет. */
|
||||||
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); }
|
.brand-headline { max-width: none; }
|
||||||
.brand-sub { max-width: none; }
|
.brand-sub { max-width: none; }
|
||||||
.brand-stats { flex-wrap: wrap; }
|
.brand-stats { flex-wrap: wrap; }
|
||||||
.stat-glass { flex: 1 1 140px; min-width: 0; }
|
.stat-glass { flex: 1 1 140px; min-width: 0; }
|
||||||
|
|||||||
@@ -497,8 +497,14 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
|
|
||||||
.chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; }
|
.chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; }
|
||||||
|
|
||||||
|
/* `min-height: 0` обязателен — тот же приём, что у `.stage-side` (комментарий
|
||||||
|
выше): без него `.chat-panel` (flex-колонка) в Firefox не сжимает
|
||||||
|
`.chat-messages` до высоты `flex:1`, а даёт ей вырасти по контенту
|
||||||
|
(список сообщений) и вылезти за пределы панели — Chrome в этой ситуации
|
||||||
|
более снисходителен, Firefox — нет. */
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -540,6 +546,13 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
.chat-input-row textarea {
|
.chat-input-row textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* Firefox даёт `<textarea>` большую автоматическую минимальную ширину,
|
||||||
|
завязанную на атрибут `cols` (умолчание 20 символов моноширинной
|
||||||
|
метрики), и как flex-item без `min-width:0` отказывается сжиматься
|
||||||
|
ниже нее — панель шириной 320px раздувается вправо. Chrome/Safari
|
||||||
|
считают минимальную ширину textarea мягче, поэтому баг был виден
|
||||||
|
только в Firefox. */
|
||||||
|
min-width: 0;
|
||||||
resize: none;
|
resize: none;
|
||||||
background: var(--color-room-tile);
|
background: var(--color-room-tile);
|
||||||
border: 1px solid var(--color-room-tile-border);
|
border: 1px solid var(--color-room-tile-border);
|
||||||
@@ -592,13 +605,18 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
|
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
|
||||||
неполной последней строки. */
|
неполной последней строки. `max-width` — страховка на случай совсем узкого
|
||||||
|
viewport: фикс-ширина 220px без потолка сама по себе не переполняется при
|
||||||
|
текущей раскладке (триггер у левого края панели, попап растёт вправо в
|
||||||
|
свободное место — проверено геометрией и вживую), но фиксированный размер
|
||||||
|
совсем без ограничителя — плохая практика сама по себе. */
|
||||||
.chat-emoji-popover {
|
.chat-emoji-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + var(--space-2));
|
bottom: calc(100% + var(--space-2));
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
width: 220px;
|
width: 220px;
|
||||||
|
max-width: calc(100vw - 2 * var(--space-4));
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -609,14 +627,30 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
box-shadow: var(--shadow-room-panel);
|
box-shadow: var(--shadow-room-panel);
|
||||||
}
|
}
|
||||||
.chat-emoji-popover .chat-emoji-option {
|
.chat-emoji-popover .chat-emoji-option {
|
||||||
|
/* Настоящая причина переполнения (найдена по факту, не по догадке —
|
||||||
|
`getBoundingClientRect` показал кнопки 42×42px при колонке ~36px):
|
||||||
|
`.chat-input-row button` (специфичность 0,1,1) задаёт ВСЕМ кнопкам
|
||||||
|
формы `width/height: 42px` — это правило круглой кнопки «Отправить»,
|
||||||
|
а кнопки эмодзи в поповере тоже лежат внутри `.chat-input-row`
|
||||||
|
(см. комментарий выше про гонку специфичности, она чинилась для
|
||||||
|
цвета в 0.0.19, но не для размера). Без явного `width`/`height` здесь
|
||||||
|
побеждает тот 42px, сетка на 5 колонок раздувается за 220px попапа,
|
||||||
|
и последняя колонка уезжает вправо за рамку. `min-width: 0` сам по
|
||||||
|
себе НЕ помогает — конфликт не в авто-минимуме грида, а в explicit
|
||||||
|
width, который обязательно нужно перебить явно. */
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
min-width: 0;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost"
|
|||||||
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
|
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
|
||||||
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
|
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
|
||||||
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
|
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
|
||||||
|
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
|
||||||
|
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
|
||||||
|
# .env (для discoverability), а не отсутствует молча.
|
||||||
|
ensure_default TURN_TLS_HOST ""
|
||||||
|
|
||||||
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
|
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
|
||||||
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
|
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
|
||||||
|
|||||||
Reference in New Issue
Block a user