18 Commits

Author SHA1 Message Date
8b63c24332 release: версия 0.0.24
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 01:58:32 +03:00
cd5399f88a fix(auth): заголовок вылезал за брендовую панель на широком окне
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Кегль «Ваша инфраструктура» уменьшался только в @media по ширине ОКНА —
не спасало, когда окно широкое, а панель (42% от него, `.layout` flex
42/58) сама по себе узкая: слово не помещалось, overflow:hidden обрезал
его вместо переноса. Перешли на container query (`container-type:
inline-size` на .brand-panel + `cqw` на .brand-headline) — тот же приём,
что у аватара участника в комнате (container-type:size + cqmin,
room.css). Кегль теперь считается от реальной ширины панели, а не окна,
в любой раскладке.

Тем же механизмом клипались плашки .brand-stats («AI-саммари») — добавлен
flex-wrap в базовое правило вместо только-мобильного.
2026-08-03 01:56:42 +03:00
2be799b19d fix(room): эмодзи-поповер в чате — 5-я колонка вылезала за рамку
Настоящая причина (подтверждена вживую через getBoundingClientRect):
`.chat-input-row button` (стиль круглой кнопки «Отправить», width/height
42px) красил размер и кнопкам эмодзи внутри поповера — они тоже лежат в
.chat-input-row. Сетка на 5 колонок по 42px требовала ~238px при
фиксированной ширине попапа 220px, лишнее уезжало вправо за рамку.
Явные width/height: auto на .chat-emoji-option перебивают унаследованный
размер нужной специфичностью, дальше квадрат считает aspect-ratio от
реальной ширины колонки (~36px). Заодно — max-width на попап и
overflow:hidden/white-space:nowrap на кнопки как общая страховка от
переполнения на очень узких экранах.
2026-08-03 01:56:27 +03:00
6b9d0b833e fix(room): чат вылезает за панель в Firefox — min-width/min-height:0
`.chat-messages` (flex-колонка) без явного min-height:0 не сжимается до
flex:1 в Firefox и растёт по контенту списка сообщений. Textarea в
.chat-input-row без min-width:0 упирается в автоматическую минимальную
ширину, которую Firefox считает от атрибута cols (умолчание 20 символов) —
жёстче, чем Chrome/Safari.
2026-08-03 01:56:03 +03:00
f4e8f91839 release: версия 0.0.23
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 22:29:34 +03:00
fb50c5d8ea docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00
826a0a391a fix(coturn): verbose-логирование — иначе ALLOCATE/CreatePermission не видны в docker logs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Дефолтный simple-log в coturn не пишет построчно ALLOCATE/CreatePermission/
Refresh даже при реально работающем relay (найдено на релизе 0.0.22:
рабочий звонок через TURN, но grep -ci allocate по логам coturn = 0,
подтверждение пришлось брать из логов LiveKit). Добавлен флаг verbose
(умеренный режим, не Verbose/-V — тот слишком шумный). DEPLOYMENT.md
предупреждает, что проверка через grep -ci allocate надёжна только с
этим флагом.
2026-08-02 22:11:32 +03:00
4f5f336cd6 release: версия 0.0.22
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 20:27:36 +03:00
0441f4f72b docs(deploy): описать включение TURN over TLS, обновление сертификата, порты
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Раздел 8: как включить TURN_TLS_HOST, почему 443 недоступен coturn без
SNI-мультиплексора (порт уже занят nginx через Docker port-publish),
пошаговая проверка (openssl s_client, аллокации в логах). Раздел 5:
deploy-hook certbot теперь перекопирует сертификат в coturn-certs-init и
перезапускает coturn при продлении — без этого TLS-TURN тихо остановится
через ~60 дней со старым сертификатом.
2026-08-02 20:27:01 +03:00
9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00
45c997380f release: версия 0.0.21
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 19:57:51 +03:00
173d384f06 feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00
7b2427535a release: версия 0.0.20
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 12:34:04 +03:00
8e45038251 fix(livekit): один UDP-порт вместо диапазона на 101 порт
Диапазон 54000-54100/udp заставлял Docker поднимать по отдельному
docker-proxy на каждый порт — весь медиатрафик шёл лишним userland-хопом.
rtc.udp_port переключает LiveKit на мультиплексирование ICE-сессий через
один порт; TURN и остальные связи (redis/nginx/webhook/prometheus) не
задеты. Проверено локально lk load-test — 0% потерь пакетов, ICE во всех
сессиях выбирает новый порт.
2026-08-02 12:33:53 +03:00
286c01d93b release: версия 0.0.19
Some checks failed
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-02 05:03:05 +03:00
e8af2fce10 fix(room): адаптивный тулбар — плавное сжатие кнопок вместо оверфлоу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Задачи B1/B2 добавили в тулбар «Рука» и «Очередь» (до 11 кнопок вместо
исходных 8) — на промежуточных ширинах (~600–1100px) кнопки вылезали за
края тулбара: жёсткого мобильного брейкпоинта (≤600px, скрывает три
кнопки) не хватало, а до него сжатия не было вовсе.

Иконка/отступы/зазоры/шрифт кнопок теперь плавно уменьшаются на диапазоне
1200px → 600px через `clamp()` с явной линейной интерполяцией (обычный
`clamp(min, Nvw, max)` не подошёл — подобранный N упирался в потолок уже на
довольно широких экранах, сжатие получалось резким скачком заметно раньше
нужной ширины, а не плавным). Нижние границы совпадают с тем, что жёстко
выставляет мобильный медиа-запрос на 600px — переход в него визуально
бесшовный.

Побочный эффект сжатия: подпись «Мини-окно» на промежуточных ширинах
переносилась на 2 строки и делала эту кнопку выше соседних. Ниже 1200px
показываем короткое «Мини» вместо полной подписи — кнопка остаётся
однострочной; aria-label по-прежнему несёт полный смысл для скринридеров.
2026-08-02 05:00:22 +03:00
d7ae462ed5 fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном
(.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это
избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu —
тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu):
своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо
боковой панели. Кнопка «Очередь» больше не получает open-состояние
пропсами сверху — сама решает, открыта ли, и сама проверяет
useIsOrganizer().

Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках),
но не бесконечно — после ~10 строк упирается в max-height и скроллится
дальше.
2026-08-02 04:58:56 +03:00
826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри
.chat-input-row (форма отправки) — той же формы, что и круглая зелёная
кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого
класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим
правилом не хватало: все кнопки поповера красились в зелёный независимо от
порядка объявления в файле — оттуда и «поплывший» вид после добавления
коня, дело было не в самом коне. Каждый селектор уточнён родительским
классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку
специфичности.

Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены
🦾 🚀 🦞 💯 🤷‍♂️, теперь 30 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00
39 changed files with 1307 additions and 199 deletions

View File

@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
# скриптом deploy/render-templates.sh (вызывается install.sh).
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 (через пробел, все — в server_name).
NGINX_SERVER_NAMES=example.com www.example.com
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.18
VIDCONF_VERSION=0.0.24
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,125 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](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
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
### Добавлено
- Настройка инстанса «Потолок качества публикации видео» (без ограничения /
720p / 360p / 180p) — режет битрейт исходящего видео публикующего через
`publishDefaults` LiveKit, снижает нагрузку на его канал и устройство.
Дефолт — без ограничения, поведение существующих инсталляций не меняется.
- Настройка инстанса «Максимум плиток на экране» (25 / 16 / 9 / 4) — участники
сверх лимита уходят на следующую страницу сетки вместо подписки на их
видеотреки, меньше одновременных видеопотоков на канал и экран участника.
Дефолт — 25 (текущий максимум сетки 5×5), без изменений.
- Обе настройки доступны в новой карточке «Нагрузка» вкладки «Настройки»
админки и отдаются участнику вместе с токеном входа в конференцию — ещё до
подключения к комнате, чтобы применяться до публикации трека и не вызывать
переподключение уже вошедших участников при смене настройки.
## [0.0.20] — 2026-08-02
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
### Исправлено
- Весь медиа-трафик конференций шёл через userland-прокси Docker: диапазон
`54000-54100/udp` заставлял поднимать по отдельному процессу `docker-proxy`
на каждый порт. LiveKit переведён на `rtc.udp_port` (один порт,
мультиплексирование ICE-сессий по ufrag внутри самого сервера) — проброс
портов схлопнут до одного, TURN не затронут. Проверено локально
синтетической нагрузкой (`lk load-test`, 2 видео + 2 аудио publisher'а,
2 subscriber'а) — 0% потерь пакетов, ICE во всех сессиях выбирает новый
единственный порт.
## [0.0.19] — 2026-08-02
Правки по замечаниям к части B (комната конференции).
### Исправлено
- Очередь поднятых рук открывалась боковой панелью во весь экран на
мобильном — теперь компактный поповер над кнопкой (как «Вид»), размер
подстраивается под число записей, после ~10 строк список скроллится.
- Кнопки нижнего тулбара при сужении окна вылезали за края блока (задачи
B1/B2 добавили «Рука»/«Очередь», в тулбаре стало до 11 кнопок вместо
восьми) — теперь плавно уменьшаются на диапазоне 1200600px вместо
жёсткого скачка на мобильный вид.
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 2 строки и
делала эту кнопку выше соседних — ниже 1200px показывается короткое
«Мини».
- Эмодзи-поповер в чате красил все свои кнопки в зелёный цвет кнопки
«Отправить» (гонка специфичности CSS-селекторов) — исправлено; заодно
сетка приведена к ровным 5×6 без неполной строки, добавлены
🦾 🚀 🦞 💯 🤷‍♂️.
## [0.0.18] — 2026-08-01
Ограничение частоты запросов больше не блокирует вход целой конференции.

View File

@@ -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).
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
### Docker Compose профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.18
0.0.24

View File

@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
)

View File

@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
"""Потолок качества исходящего видео участника (задача «рычаги качества
медиа»). `off` — без ограничения (дефолт, поведение как до появления
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
StageMaxTiles = Literal[4, 9, 16, 25]
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
режет набор доступных раскладок сетки, что бросает лишних участников на
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
class MediaLimitsConfig(BaseModel):
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
конференции — статичные потолки, применяются на клиенте при входе)."""
publish_quality_cap: PublishQualityCap = "off"
stage_max_tiles: StageMaxTiles = 25
class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False
contact_email: str | None = None
# Потолок качества публикации + максимум плиток сцены — см.
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
# не только в админке — настройка должна быть на руках у клиента до
# публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)

View File

@@ -6,7 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from core.plugins.config import AiLevel, SummaryRecipientsMode
from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool
contact_email: str | None = None
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class TestEmailIn(BaseModel):

View File

@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode
from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.room_events import ForcedMuteSource
from services.recurrence import RecurrenceRule
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class ConferenceOut(BaseModel):

View File

@@ -9,6 +9,7 @@
import json
from core.config import get_settings
from core.plugins.config import PublishQualityCap, StageMaxTiles
from core.security import verify_password
from models.conference import Conference
from schemas.conferences import JoinOut
@@ -52,15 +53,18 @@ def build_join(
identity: str,
name: str,
chat_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
читается здесь параметром, а не заново из БД, чтобы не плодить
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
стороной значения `instance_settings`: читаются здесь параметрами, а не
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
@@ -87,4 +91,6 @@ def build_join(
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
)

View File

@@ -149,12 +149,14 @@ class ConferenceService:
join = None
if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
cfg = await InstanceSettingsService(self._session).get()
join = build_join(
conference,
identity=str(owner_id),
name=owner_name,
chat_enabled=chat_enabled,
chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
)
@@ -243,12 +245,14 @@ class ConferenceService:
"""Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id)
await ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
cfg = await InstanceSettingsService(self._session).get()
return build_join(
conference,
identity=str(user.id),
name=user.name_user,
chat_enabled=chat_enabled,
chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
)
@@ -265,12 +269,14 @@ class ConferenceService:
await self._session.flush()
await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
cfg = await InstanceSettingsService(self._session).get()
return build_join(
conference,
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=chat_enabled,
chat_enabled=cfg.chat.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
)
async def mute_participant(

View File

@@ -2,7 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
@@ -28,7 +29,10 @@ from core.plugins.config import (
AiLevel,
ChatConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig,
SummaryRecipientsMode,
TranscriberConfig,
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -103,6 +109,8 @@ class SettingsUpdateIn(BaseModel):
registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None
contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
class BootstrapOverrides(BaseModel):
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
}
if overrides is None:
return defaults
@@ -340,6 +349,20 @@ class InstanceSettingsService:
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
await self._session.commit()
return cfg
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"enabled", False
),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
)

View File

@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_400(
assert response.status_code == 400
async def test_get_settings_media_limits_defaults(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Дефолты рычагов нагрузки — без ограничения качества и с текущим
максимумом сетки (5×5) — существующие инсталляции после обновления не
получают внезапно ухудшенное качество."""
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "off"
assert body["stage_max_tiles"] == 25
async def test_put_settings_media_limits_partial_update(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "360p", "stage_max_tiles": 9},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "360p"
assert body["stage_max_tiles"] == 9
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["publish_quality_cap"] == "360p"
assert reloaded.json()["stage_max_tiles"] == 9
async def test_put_settings_media_limits_invalid_values_return_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса
ДО сервисного слоя, ещё на уровне FastAPI/pydantic."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
bad_cap = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "4k"},
headers=_auth_headers(admin),
)
assert bad_cap.status_code == 422
bad_tiles = await client.put(
"/api/v1/admin/settings",
json={"stage_max_tiles": 100},
headers=_auth_headers(admin),
)
assert bad_tiles.status_code == 422
# --- Тестовое письмо ----------------------------------------------------------------

View File

@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"]
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -841,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
assert guests[0].email == "alice-guest@example.com"
async def test_guest_join_reflects_admin_configured_media_limits(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
(см. `services/conference_access.py::build_join`), гость админку не видит."""
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session)
await db_session.commit()
settings_response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
headers=_auth_headers(admin),
)
assert settings_response.status_code == 200, settings_response.text
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "180p"
assert body["stage_max_tiles"] == 4
async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:

View File

@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
)
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
@@ -137,6 +139,11 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False
assert cfg.contact_email is None
# Дефолты сохраняют текущее (до появления настройки) поведение —
# без ограничения качества и с максимумом сетки, равным фактическому
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
assert cfg.summarizer.enabled is False
async def test_media_limits_partial_update_keeps_untouched_field(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
живут в одной строке JSON, `update()` обязан подставлять текущее значение
несменённого поля, а не дефолт модели."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
assert cfg.media_limits.publish_quality_cap == "360p"
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
assert cfg.media_limits.stage_max_tiles == 9
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
reloaded = await service.get()
assert reloaded.media_limits.publish_quality_cap == "360p"
assert reloaded.media_limits.stage_max_tiles == 9
async def test_update_rejects_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -12,8 +12,8 @@
# затрутся при следующем рендере.
listening-port=3478
# Установить 5349 в проде для TURN over TLS, если будут смонтированы
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже).
# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
tls-listening-port=5349
# Диапазон relay-портов для TURN-аллокаций.
@@ -31,14 +31,30 @@ fingerprint
# Без CLI/telnet admin-интерфейса в этой поставке.
no-cli
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить
# TURN over TLS на 443:
# cert=/etc/coturn/certs/cert.pem
# pkey=/etc/coturn/certs/key.pem
# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
# Без 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
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-сети
# (network_mode: host здесь не даёт coturn определить публичный IP
# автоматически). Для локальной разработки (без внешних участников)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.18}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.24}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -407,16 +407,19 @@ services:
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются
# нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
# публичными.
ports:
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
- "54000-54100:54000-54100/udp" # WebRTC media (ICE)
# Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
# мультиплексирует все ICE-сессии через него (rtc.udp_port в
# livekit.yaml.template), а не открывает по порту на участника.
# На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
# 101 порт держали 101 лишний userland-процесс на медиапути.
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
# macOS заняты Steam/системными процессами и эфемерными QUIC.
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
depends_on:
redis:
condition: service_healthy
@@ -429,6 +432,49 @@ services:
profiles: ["media"]
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:
image: coturn/coturn:latest
restart: unless-stopped
@@ -441,6 +487,10 @@ services:
# (deploy/render-templates.sh, вызывается install.sh).
volumes:
- ./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
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
# curl/wget, поэтому проверка процесса по имени не работает
@@ -884,6 +934,11 @@ volumes:
# Загруженные пользователями файлы (аватары) — общий том между
# backend (запись при загрузке) и nginx (раздача статики, `location /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_data:
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.

View File

@@ -15,16 +15,20 @@ port: 7880
rtc:
tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
# расширить и синхронизировать с пробросом портов.
port_range_start: 54000
port_range_end: 54100
# Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
# Раньше здесь был port_range_start/port_range_end (54000-54100) — под
# каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
# (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
udp_port: 54000
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
# разработки (Docker Desktop): use_external_ip=true определяет публичный
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент
# 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
# запускается на том же хосте.
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
@@ -50,10 +54,24 @@ rtc:
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
#
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
# прежде чем перейти к рабочему кандидату.
# UDP и TCP на 3478 — оба порта уже открыты в ufw.
#
# 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:
- host: ${TURN_EXTERNAL_IP}
port: 3478
@@ -65,6 +83,13 @@ rtc:
protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET}
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/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей;

View File

@@ -23,13 +23,27 @@ fi
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
# (интерпретируется как редирект) при попытке `source` файла целиком.
#
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
# существующих .env).
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_REALM="$(env_var TURN_REALM)"
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_NODE_IP="$(env_var LIVEKIT_NODE_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)}"
: "${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}' \
< "$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
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `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"
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
echo "[render] deploy/livekit/livekit.yaml готов"
envsubst '${REDIS_PASSWORD}' \

View File

@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
"registration_team_choice": false,
"registration_email_domain_enabled": false,
"registration_email_domain": null,
"transcription_queue_served": true
"transcription_queue_served": true,
"publish_quality_cap": "off",
"stage_max_tiles": 25
}
```
@@ -408,6 +410,8 @@ Email сразу считается подтверждённым (письмо
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
- `publish_quality_cap` — потолок качества исходящего видео публикующего: `off` (без ограничения, дефолт), `720p`, `360p` или `180p`; отдаётся участнику ещё и в join-ответе (`JoinOut`, `POST /api/v1/conferences/{id}/join`/`guest-join`) — до входа в LiveKit-комнату
- `stage_max_tiles` — максимум одновременно видимых плиток сетки конференции: `4`, `9`, `16` или `25` (дефолт, совпадает с текущим потолком сетки 5×5, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
---

View File

@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
@@ -237,7 +237,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/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
# 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
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
@@ -276,8 +291,9 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
# 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100,
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
# если включили TURN over TLS — раздел 8)
ss -ltnp
# 3. Redis требует пароль (НЕ должен пускать без него)
@@ -318,7 +334,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1,
`LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение
@@ -358,15 +374,107 @@ state: connected`, но собеседник не видит видео/не с
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите 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) — не настроен.** Это самый надёжный
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
`deploy/coturn/turnserver.conf.template`, добавить volume с
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
клиента ждать таймаута перед переходом к рабочему кандидату.
### TURN over TLS (порт 5349)
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
**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).
---

View File

@@ -1,5 +1,9 @@
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
> и готовой таблицы «профиль нагрузки → железо» см.
> [hardware-sizing.md](hardware-sizing.md).
Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
@@ -179,12 +183,15 @@ dev-стека.
профилей битрейта заводить не требуется; при необходимости ограничить
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
(клиентский SDK, вне скоупа devops-части).
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной
ступени (максимум 60 участников в тесте) — при планировании прод-узла с
ожидаемым бОльшим числом одновременных участников across все комнаты
узла держать `port_range_end - port_range_start` заметно больше пикового
числа участников на узле (LiveKit резервирует пару портов на участника
на медиа-транспорт).
4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
не был узким местом ни на одной ступени (максимум 60 участников). Тогда
же с ним была цена: под каждый порт диапазона Docker держал отдельный
процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
диапазон шире пикового числа участников» больше не актуальна — портов
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
поверх одного сокета сам.
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
проде, а не только для теста — иначе клиенты в вырожденном случае

View File

@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода

View 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 | 140169 Мбит/с устойчиво (пик 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 намерены именно под устойчивые
140169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
такому коэффициенту недооценит нужное железо.
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
разы, см. следующий раздел.
## Почему нельзя считать «все видят всех»
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
`N × (N1) × битрейт` (каждый участник получает поток от каждого) — при 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 вообще
годится для конференций на десятки участников.
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
25 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
## Формула
```
1. подписок = камер × (участников 1)
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
2. видео = подписок × битрейт_на_подписку
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
(часть подписок физически не на экране — почти не потребляет полосы)
3. аудио = активных_микрофонов × участников × 40 кбит/с
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
+ 12 ядра на остальной стек (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: 140169 Мбит/с устойчиво ÷ 1.62 ядра =
86104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
ядер).
- Минимум 2 ядра и запас 12 ядра на остальной стек — эмпирический пол: на
обоих реальных замерах 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 ядра | 46 ГБ | полоса и её цена у хостера |
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
LiveKit плюс 12 под остальной стек означают, что 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.31.5 Мбит/с` (зависит от «Потолка
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
собрания на 75 человек с 15 камерами это 4.522.5 Мбит/с входящих — заметно
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
участников совещания, может упереться в свой исходящий/входящий канал раньше,
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
сетевой инфраструктуры на стороне участников.
## Оговорка про канал и его стоимость
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
оказывается почти всегда не CPU — оба реальных замера показали комфортный
запас (1.61.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⌉) + 12` на остальной стек.
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, порты, что открыть в файрволе под
медиа-трафик.

View File

@@ -4,7 +4,7 @@
* конверте пагинации `items`/`total`.
*/
import { apiRequest } from '@/api/client'
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
export type AiLevel = 'min' | 'medium' | 'max'
@@ -39,6 +39,10 @@ export interface SettingsOut {
contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -56,6 +60,8 @@ export interface SettingsUpdateIn {
/** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean
contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
*/
export type SummaryRecipientsMode = 'all' | 'owner'
/**
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
* ограничения. Применяется на клиенте через `publishDefaults`
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
* жёсткое разрешение захвата камеры.
*/
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
/**
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
}
/**

View File

@@ -10,7 +10,7 @@ import {
type SettingsUpdateIn,
type TestEmailOut,
} from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences'
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select'
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'owner', label: 'Только организатору' },
]
const PUBLISH_QUALITY_CAP_OPTIONS = [
{ value: 'off', label: 'Без ограничения' },
{ value: '720p', label: 'Не выше 720p' },
{ value: '360p', label: 'Не выше 360p' },
{ value: '180p', label: 'Не выше 180p' },
]
const STAGE_MAX_TILES_OPTIONS = [
{ value: '25', label: '25 (5×5, без ограничения)' },
{ value: '16', label: '16 (4×4)' },
{ value: '9', label: '9 (3×3)' },
{ value: '4', label: '4 (2×2)' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний',
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail
}
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
mutation.mutate(payload)
}
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div>
</section>
<section className="settings-card">
<h2>Нагрузка</h2>
<p className="desc">
Рычаги для инстансов на слабом канале/железе режут исходящий трафик и нагрузку на
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
</p>
<div className="settings-card-body settings-card-body--spread">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
Потолок качества публикации видео
</label>
<Select
id="settings-quality-cap"
aria-labelledby="settings-quality-cap-label"
value={publishQualityCap}
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
options={PUBLISH_QUALITY_CAP_OPTIONS}
/>
<p className="field-hint">
Ограничивает битрейт исходящего видео публикующего снижает нагрузку на его канал и
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
стороны отдельно)
</p>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
Максимум плиток на экране
</label>
<Select
id="settings-max-tiles"
aria-labelledby="settings-max-tiles-label"
value={String(stageMaxTiles)}
onChange={(v) => setStageMaxTiles(Number(v))}
options={STAGE_MAX_TILES_OPTIONS}
/>
<p className="field-hint">
Сверх лимита участники уходят на следующую страницу сетки вместо подписки меньше
одновременных видеопотоков на канал и экран участника
</p>
</div>
</div>
</section>
<section className="settings-card">
<h2>Контактный адрес</h2>
<p className="desc">

View File

@@ -6,13 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
/**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере.
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
* добавляя/убирая эмодзи, держи кратность 5).
*/
const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎',
'🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
]
interface ChatPanelProps {

View File

@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueueMenuProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
}
/**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её
* во весь экран незачем и на мобильном.
*
* Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече
* получил бы поповер выше экрана.
*/
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer()
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleKeydown)
}
}, [open])
if (!isOrganizer) return null
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
type="button"
className={`tb-btn${open ? ' is-panel-open' : ''}`}
aria-pressed={open}
aria-expanded={open}
aria-haspopup="dialog"
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={() => setOpen((v) => !v)}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{queue.length > 0 && (
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
{open && (
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</div>
)}
</div>
)
}

View File

@@ -1,60 +0,0 @@
import { Hand, X } from 'lucide-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
interface HandQueuePanelProps {
queue: HandQueueEntry[]
onLower: (identity: string) => void
onClose: () => void
}
/**
* Панель очереди поднятых рук — видна только организатору (задача B1).
* Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`,
* включая мобильное поведение «во весь экран» на ≤900px), содержимое своё:
* упорядоченный список с позицией и кнопкой «Опустить» на каждой строке —
* организатору разрешено опускать чужую руку (решение оператора, задача B1).
*
* `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по
* `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не
* единственная защита: `RoomToolbar` уже не показывает кнопку открытия
* не-организатору, это подстраховка на случай прямого рендера.
*/
export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) {
const isOrganizer = useIsOrganizer()
if (!isOrganizer) return null
return (
<aside className="chat-panel hand-queue-panel">
<div className="chat-head">
<h2>Поднятые руки</h2>
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
<X className="lucide" aria-hidden="true" />
</button>
</div>
{queue.length === 0 ? (
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
<button
type="button"
className="hand-queue-lower"
onClick={() => onLower(entry.identity)}
>
Опустить
</button>
</li>
))}
</ol>
)}
</aside>
)
}

View File

@@ -206,6 +206,7 @@ export function RoomStage({
onPinFocus,
raisedHandIdentities,
conferenceId,
stageMaxTiles,
}: {
variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -232,6 +233,8 @@ export function RoomStage({
raisedHandIdentities?: Set<string>
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
conferenceId?: string
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
stageMaxTiles?: number
}) {
const room = useRoomContext()
const isCompact = useIsCompactViewport()
@@ -429,14 +432,14 @@ export function RoomStage({
function renderMain(): ReactNode {
if (effectiveMode === 'tiles') {
return (
<StageGrid tracks={tracks}>
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
}
if (effectiveMode === 'live-tiles') {
return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)
@@ -445,7 +448,7 @@ export function RoomStage({
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
if (sideTracks.length === 0 && !hideOthers) {
return (
<StageGrid tracks={tracks}>
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} />
</StageGrid>
)

View File

@@ -1,6 +1,5 @@
import {
Hand,
ListOrdered,
LogOut,
Maximize,
MessageSquare,
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
handQueue: HandQueueEntry[]
onRaiseHand: () => void
onLowerHand: () => void
handQueueOpen: boolean
onToggleHandQueue: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void
}
/**
@@ -104,8 +103,7 @@ export function RoomToolbar({
handQueue,
onRaiseHand,
onLowerHand,
handQueueOpen,
onToggleHandQueue,
onLowerHandById,
layoutMode,
onLayoutModeChange,
hideOthers,
@@ -113,7 +111,6 @@ export function RoomToolbar({
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
const mic = useTrackToggle({ source: Track.Source.Microphone })
@@ -197,23 +194,7 @@ export function RoomToolbar({
<span className="label">Рука</span>
</button>
{isOrganizer && (
<button
type="button"
className={`tb-btn${handQueueOpen ? ' is-panel-open' : ''}`}
aria-pressed={handQueueOpen}
aria-label={handQueueOpen ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
onClick={onToggleHandQueue}
>
<span className="icon-shell">
<ListOrdered className="lucide" aria-hidden="true" />
{handQueue.length > 0 && (
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
)}
</span>
<span className="label">Очередь</span>
</button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
{!isCompact && (
<StageViewMenu
@@ -266,7 +247,14 @@ export function RoomToolbar({
<span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" />
</span>
<span className="label">Мини-окно</span>
{/* На узком экране («Мини-окно» иначе переносится на 2 строки и
кнопка становится выше соседних, см. .label-full/.label-short
в room.css) — только «Мини». Текст, не структура: aria-label
выше уже несёт полный смысл независимо от видимой подписи. */}
<span className="label">
<span className="label-full">Мини-окно</span>
<span className="label-short">Мини</span>
</span>
</button>
)}

View File

@@ -1,4 +1,4 @@
import { useRef, type ReactNode, type RefObject } from 'react'
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
TrackLoop,
@@ -47,6 +47,17 @@ interface StageGridProps {
tracks: TrackReferenceOrPlaceholder[]
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
children: ReactNode
/**
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
* то есть перестают быть подписанными треками, а не просто визуально мельче.
*/
maxTiles?: number
}
/**
@@ -64,7 +75,7 @@ interface StageGridProps {
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
*/
export function StageGrid({ tracks, children }: StageGridProps) {
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
const gridEl = useRef<HTMLDivElement | null>(null)
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
// параметр уже не присваивается — приведение безопасно: оба хука только
// читают `.current` (ResizeObserver и слушатели touch-событий).
const gridRef = gridEl as RefObject<HTMLDivElement>
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
const gridLayouts = useMemo(
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
[maxTiles],
)
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridRef, {

View File

@@ -0,0 +1,36 @@
import { VideoPresets, type TrackPublishDefaults } from 'livekit-client'
import type { PublishQualityCap } from '@/api/conferences'
/**
* Потолок качества публикации → `TrackPublishDefaults` для `RoomOptions.publishDefaults`
* (см. `RoomPage.tsx`, `roomOptions`).
*
* Ограничивается только `videoEncoding` (битрейт/framerate верхнего слоя
* симулкаста) и набор дополнительных слоёв `videoSimulcastLayers` — НЕ
* фактическое разрешение захвата камеры (`videoCaptureDefaults`, трогать
* его не входит в задачу). WebRTC сам подстраивает реальное разрешение
* кодирования под урезанный битрейт (`degradationPreference`), поэтому
* проверять эффект нужно по фактическому битрейту исходящего видео, а не по
* заявленному разрешению потока.
*
* Слои каждого потолка — все пресеты LiveKit НИЖЕ и РАВНО потолку (без
* дефолтного «h180, h360», который иначе подставился бы сам при пустом
* `videoSimulcastLayers` и мог бы превысить потолок 180p).
*/
const PUBLISH_DEFAULTS_BY_CAP: Record<Exclude<PublishQualityCap, 'off'>, TrackPublishDefaults> = {
'180p': { videoEncoding: VideoPresets.h180.encoding, videoSimulcastLayers: [] },
'360p': { videoEncoding: VideoPresets.h360.encoding, videoSimulcastLayers: [VideoPresets.h180] },
'720p': {
videoEncoding: VideoPresets.h720.encoding,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360],
},
}
/**
* `off` — `undefined`: `publishDefaults` не задаётся вовсе, поведение
* библиотеки не отличается от состояния до появления настройки (см.
* критерий готовности «дефолты сохраняют текущее поведение»).
*/
export function buildPublishDefaults(cap: PublishQualityCap): TrackPublishDefaults | undefined {
return cap === 'off' ? undefined : PUBLISH_DEFAULTS_BY_CAP[cap]
}

View File

@@ -146,6 +146,8 @@ export function JoinPage() {
title: resolved.title,
conferenceId: data.conference_id,
chatEnabled: data.chat_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
},
})
} catch (err) {

View File

@@ -43,6 +43,8 @@ export function LobbyPage() {
title: conference.title,
conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled,
publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number,
slug: conference.slug,
},

View File

@@ -6,7 +6,7 @@ import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react
import type { RoomOptions } from 'livekit-client'
import '@livekit/components-styles'
import '@/styles/room.css'
import { joinConference, resolveConference } from '@/api/conferences'
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat'
@@ -16,10 +16,10 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
interface RoomJoinState {
@@ -32,6 +32,18 @@ interface RoomJoinState {
number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean
/**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
* первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
* стабильность его ссылки): `joinState` целиком появляется одним актом
* (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
* (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
* на руках к моменту публикации трека, без отдельного асинхронного
* похода за настройками после подключения и без риска переподключения.
*/
publishQualityCap?: PublishQualityCap
stageMaxTiles?: number
}
/**
@@ -98,6 +110,8 @@ export function RoomPage() {
title: info.title,
conferenceId: result.conference_id,
chatEnabled: result.chat_enabled,
publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles,
})
}
} catch (err) {
@@ -147,11 +161,6 @@ export function RoomPage() {
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
const [handQueueOpen, setHandQueueOpen] = useState(false)
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo(
@@ -208,9 +217,16 @@ export function RoomPage() {
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
// не делаем (сохранение — только в DeviceSettingsDialog).
//
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
// подключённого участника.
const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>(
() => ({
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
@@ -236,7 +252,7 @@ export function RoomPage() {
// (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}),
[userChoices],
[userChoices, joinState?.publishQualityCap],
)
if (error) {
@@ -293,6 +309,7 @@ export function RoomPage() {
onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId}
stageMaxTiles={joinState.stageMaxTiles}
/>
)}
{chatVisible && chatOpen && (
@@ -304,13 +321,6 @@ export function RoomPage() {
onClose={() => setChatOpen(false)}
/>
)}
{handQueueOpen && (
<HandQueuePanel
queue={chat.handQueue}
onLower={(identity) => chat.lowerHand(identity)}
onClose={() => setHandQueueOpen(false)}
/>
)}
</div>
<RoomToolbar
chatVisible={chatVisible}
@@ -327,8 +337,7 @@ export function RoomPage() {
handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()}
handQueueOpen={handQueueOpen}
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
onLowerHandById={(identity) => chat.lowerHand(identity)}
layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers}

View File

@@ -16,6 +16,11 @@
position: relative;
overflow: hidden;
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 {
content: "";
@@ -41,6 +46,12 @@
.brand-headline {
font: var(--text-display-lg);
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);
margin: 0 0 var(--space-4);
max-width: 460px;
@@ -51,7 +62,11 @@
max-width: 420px;
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 {
background: var(--color-surface-glass);
backdrop-filter: blur(16px);
@@ -248,10 +263,10 @@
right: -90px;
bottom: -90px;
}
/* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden,
обрезающий текст без переноса вместо уменьшения кегля). */
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); }
/* Кегль теперь считает `.brand-headline` сама (cqw от ширины панели, см.
базовое правило) — здесь снимаем только `max-width:460px`: в сложенной
колонкой раскладке панель может стать шире 460px, а дизайн этого хочет. */
.brand-headline { max-width: none; }
.brand-sub { max-width: none; }
.brand-stats { flex-wrap: wrap; }
.stat-glass { flex: 1 1 140px; min-width: 0; }

View File

@@ -277,32 +277,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
/*
* Кнопки тулбара плавно уменьшаются (иконка/отступы/шрифт/зазор) на всём
* диапазоне 1200px → 600px — до этого тулбар стал шире, чем при исходном
* проектировании (задачи B1/B2 добавили «Рука»/«Очередь», раньше помещались
* без сжатия 8 кнопок, теперь до 11 — без этого блока получался
* горизонтальный оверфлоу вплоть до самого мобильного брейкпоинта, кнопки
* вылезали за края тулбара).
*
* Обычный `clamp(min, Nvw, max)` тут не подходит: подобранный `N`
* дотягивается до `max` уже на довольно узких экранах (например,
* `3vw` = 24px ровно на 800px viewport) и дальше просто стоит на потолке —
* получается не плавное сжатие в нужном диапазоне, а резкий скачок сильно
* раньше нужной ширины (поймали именно так на первой версии этого блока).
* Вместо этого — явная линейная интерполяция между двумя точками
* (600px→минимум, 1200px→максимум): `calc(MIN + (MAX-MIN) * (100vw - 600px)
* / 600)`, снаружи в `clamp()` только чтобы намертво остановиться на
* границах диапазона. Нижние границы — те же значения, что жёстко
* выставляет мобильный медиа-запрос ниже (`max-width: 600px`), поэтому
* переход в него на 600px визуально бесшовный.
*/
.room-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
gap: clamp(2px, calc(2px + (100vw - 600px) * 6 / 600), var(--space-2));
background: var(--color-room-surface);
border-top: 1px solid var(--color-room-tile-border);
padding: var(--space-3) var(--space-6);
padding: var(--space-3) clamp(8px, calc(8px + (100vw - 600px) * 16 / 600), var(--space-6));
flex-shrink: 0;
}
.tb-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
background: transparent;
border: none;
padding: 8px 18px;
padding:
clamp(6px, calc(6px + (100vw - 600px) * 2 / 600), 8px)
clamp(6px, calc(6px + (100vw - 600px) * 12 / 600), 18px);
border-radius: var(--radius-md);
color: var(--color-room-text-primary);
min-width: 76px;
min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
cursor: pointer;
}
.tb-btn .icon-shell {
width: 48px;
height: 48px;
width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
border-radius: 50%;
display: flex;
align-items: center;
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
background: var(--color-room-tile);
color: var(--color-room-text-primary);
}
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; }
.tb-btn span.label {
font: var(--text-caption);
font-size: clamp(11px, calc(11px + (100vw - 600px) * 1 / 600), 12px);
text-transform: none;
letter-spacing: normal;
color: var(--color-room-text-secondary);
font-weight: 500;
}
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
/* Короткая подпись мини-окна (см. RoomToolbar.tsx) — «Мини-окно» на узком
экране переносится на 2 строки и делает эту кнопку выше соседних; ниже
порога, где начинается перенос, прячем длинный вариант и показываем
короткий «Мини» — кнопка остаётся однострочной и той же высоты, что и
остальные. */
.tb-btn .label-short { display: none; }
@media (max-width: 1200px) {
.tb-btn .label-full { display: none; }
.tb-btn .label-short { display: inline; }
}
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
@@ -457,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; }
/* `min-height: 0` обязателен — тот же приём, что у `.stage-side` (комментарий
выше): без него `.chat-panel` (flex-колонка) в Firefox не сжимает
`.chat-messages` до высоты `flex:1`, а даёт ей вырасти по контенту
(список сообщений) и вылезти за пределы панели — Chrome в этой ситуации
более снисходителен, Firefox — нет. */
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-5);
display: flex;
@@ -500,6 +546,13 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
}
.chat-input-row textarea {
flex: 1;
/* Firefox даёт `<textarea>` большую автоматическую минимальную ширину,
завязанную на атрибут `cols` (умолчание 20 символов моноширинной
метрики), и как flex-item без `min-width:0` отказывается сжиматься
ниже нее — панель шириной 320px раздувается вправо. Chrome/Safari
считают минимальную ширину textarea мягче, поэтому баг был виден
только в Firefox. */
min-width: 0;
resize: none;
background: var(--color-room-tile);
border: 1px solid var(--color-room-tile-border);
@@ -526,8 +579,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
}
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
/* Триггер и опции эмодзи-поповера лежат внутри `.chat-input-row` (форма
отправки) — тот же контейнер, что у круглой зелёной кнопки «Отправить»
(`.chat-input-row button`, специфичность 0,1,1). Голого класса
(`.chat-emoji-trigger`/`.chat-emoji-option`, 0,1,0) для победы над ней не
хватает — ЛЮБАЯ кнопка внутри формы (включая кнопки в самом поповере,
он тоже в этом поддереве) красилась в зелёный независимо от порядка
правил в файле. Каждый селектор ниже уточнён родительским классом ровно
затем, чтобы обойти именно эту гонку специфичности. */
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
.chat-emoji-trigger {
.chat-emoji-wrap .chat-emoji-trigger {
width: 42px;
height: 42px;
border-radius: 50%;
@@ -539,35 +600,61 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
justify-content: center;
cursor: pointer;
}
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
.chat-emoji-wrap .chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
.chat-emoji-wrap .chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
неполной последней строки. `max-width` — страховка на случай совсем узкого
viewport: фикс-ширина 220px без потолка сама по себе не переполняется при
текущей раскладке (триггер у левого края панели, попап растёт вправо в
свободное место — проверено геометрией и вживую), но фиксированный размер
совсем без ограничителя — плохая практика сама по себе. */
.chat-emoji-popover {
position: absolute;
bottom: calc(100% + var(--space-2));
left: 0;
z-index: 50;
width: 224px;
width: 220px;
max-width: calc(100vw - 2 * var(--space-4));
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
padding: var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--color-room-tile-border);
background: var(--color-room-surface-raised);
box-shadow: var(--shadow-room-panel);
}
.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;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: none;
border: none;
font-size: 20px;
line-height: 1;
padding: 6px;
white-space: nowrap;
border-radius: var(--radius-md);
cursor: pointer;
}
.chat-emoji-option:hover { background: var(--color-room-tile); }
.chat-emoji-popover .chat-emoji-option:hover { background: var(--color-room-tile); }
@media (max-width: 900px) {
.chat-panel {
@@ -580,16 +667,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
}
}
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ----------
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список
* — свой. */
/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
* Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
* `.chat-panel`: очередь — короткий список, а не история переписки,
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
.hand-queue-menu { width: 300px; padding: var(--space-3); }
.hand-queue-list {
list-style: none;
margin: 0;
padding: var(--space-3);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
/* Высота растёт вместе со списком (при 12 записях поповер компактный), но
не безгранично — после ~10 строк упирается в потолок и скроллится
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
max-height: 460px;
overflow-y: auto;
}
.hand-queue-item {

View File

@@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost"
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
ensure_default LIVEKIT_NODE_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
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)