24 Commits

Author SHA1 Message Date
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
a895782250 release: версия 0.0.25
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 18:24:13 +03:00
fa8270c156 fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00
a9e24f6692 fix(room): в Chrome пропадал звук после возврата из мини-окна
RoomAudioRenderer жил внутри RoomStage и рендерился дважды — в ветке
variant="pip" и в основной. При открытии Document PiP сцена
размонтируется в основном окне и монтируется в PiP-окне, поэтому
скрытые <audio> с чужими аудиотреками физически переезжали в ДРУГОЙ
документ, а при возврате — обратно. После такого переезда Chrome
теряет аудиовыход у remote-трека: пакеты продолжают приходить
(packetsReceived растёт), но totalSamplesDuration и totalAudioEnergy
замирают, и трек молчит даже в свежесозданном <audio> со свежим
MediaStream. Тот же цикл detach/attach в пределах одного документа
безвреден — дело именно в переезде между документами. В Safari бага не
было: там Document PiP не используется (video-фолбэк), сцена остаётся
в основном окне.

Рендерер вынесен в RoomPage — один экземпляр, всегда в основном
документе. В PiP-окне аудиоэлементов теперь нет вовсе, цикл «открыл
мини-окно → вернул» их не касается.
2026-08-03 18:19:19 +03:00
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
daaa480f03 release: версия 0.0.18
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:42:53 +03:00
c60047c594 fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.

1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
   поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
   Проверено на проде: в Redis лежал единственный ключ
   `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
   действовал на весь инстанс разом, а не на клиента.

2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
   открывший ссылку на конференцию в течение минуты, получал 429 — и видел
   «Не удалось найти конференцию» для существующей и активной конференции.
   Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.

Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
  `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
  через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
  клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
  конференция не найдена или пароль неверен. Именно так выглядит перебор
  номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
  флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
  правдоподобного числа участников одной конференции.

Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
2026-08-01 23:42:36 +03:00
43 changed files with 1704 additions and 267 deletions

View File

@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
# скриптом deploy/render-templates.sh (вызывается install.sh). # скриптом deploy/render-templates.sh (вызывается install.sh).
TURN_EXTERNAL_IP=127.0.0.1 TURN_EXTERNAL_IP=127.0.0.1
# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто =
# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn
# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через
# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись
# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru —
# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от
# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по
# имени хоста, а Let's Encrypt выписывает сертификат на домен.
TURN_TLS_HOST=
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template --- # --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
# Домены, которые обслуживает nginx (через пробел, все — в server_name). # Домены, которые обслуживает nginx (через пробел, все — в server_name).
NGINX_SERVER_NAMES=example.com www.example.com NGINX_SERVER_NAMES=example.com www.example.com
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.17 VIDCONF_VERSION=0.0.25
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,177 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.25] — 2026-08-03
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.
### Исправлено
- В Chrome пропадал звук других участников после возврата сцены из
мини-окна в основное. `RoomAudioRenderer` жил внутри `RoomStage` и
рендерился в обеих её ветках, поэтому скрытые `<audio>` с чужими
аудиотреками физически переезжали в документ PiP-окна и обратно.
После такого переезда Chrome теряет аудиовыход у remote-трека: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration` и `totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же цикл
detach/attach в пределах одного документа безвреден — дело именно в
переезде между документами. Рендерер вынесен в `RoomPage`: один
экземпляр, всегда в основном документе, в PiP-окне аудиоэлементов нет
вовсе. В Safari бага не было — там Document PiP не используется
(video-фолбэк), сцена из основного окна не уезжает.
- В мини-окне не действовало закрепление участника, а демонстрация
экрана слетала на говорящего от любой чужой реплики. Мини-плеер
намеренно ходил с упрощёнными правилами выбора фокуса (без
`holdScreenShare`, без приоритета говорящего с камерой, без
антидребезга) и с собственным локальным состоянием закрепления.
Теперь `pickStageFocus` получает одинаковые правила в обоих вариантах
сцены, а закрепление переезжает между окнами тем же мостиком через
`RoomPage`, что и фокус. Сознательно оставлено одно отличие —
фолбэк «показать себя» (`localKey`) только у мини-плеера: это защита
от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.
- Закрепление сбрасывалось при каждом монтировании сцены: на первом
рендере `useTracks` отдаёт пустой набор треков, и правило «закреплённый
вышел из комнаты» принимало это за уход участника.
### Изменено
- Кнопка-булавка на плитке подписана «Закрепить» вместо «Закрепить в
основном окне» — закрепление больше не ограничено основным окном.
## [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
Ограничение частоты запросов больше не блокирует вход целой конференции.
### Исправлено
- Лимит на резолв конференции и гостевой вход считался по адресу контейнера
nginx, а не клиента, — то есть «10 запросов в минуту» действовали на весь
сервер разом. Одиннадцатый человек, открывший ссылку в течение минуты,
получал отказ и видел «Не удалось найти конференцию» для существующей и
активной конференции. Теперь адрес берётся из заголовка, который nginx уже
передаёт.
- Счётчик считает только неудачные попытки — конференция не найдена или
пароль неверен. Именно так выглядит перебор номера, от которого защищает
ограничение; массовый вход по рабочей ссылке к нему отношения не имеет.
На общий поток с адреса оставлен потолок в 300 запросов в минуту.
## [0.0.17] — 2026-08-01 ## [0.0.17] — 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). Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
### Docker Compose профили (низкоуровневый контроль) ### Docker Compose профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.17 0.0.25

View File

@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
registration_email_domains=cfg.registration_email_domains, registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled, contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email, 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

@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user from api.deps import get_current_user
from core.db import get_session from core.db import get_session
from core.rate_limit import enforce_rate_limit from core.rate_limit import (
RATE_LIMIT_MISS_MAX_REQUESTS,
RATE_LIMIT_SOFT_MAX_REQUESTS,
client_ip,
enforce_rate_limit,
)
from models.user import User from models.user import User
from schemas.conferences import ( from schemas.conferences import (
ConferenceCreateIn, ConferenceCreateIn,
@@ -103,10 +108,18 @@ async def resolve_conference(
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции. join/guest-join), а признак закрытости неактуален для мёртвой конференции.
""" """
await enforce_rate_limit(f"resolve:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
# вся конференция открывает ссылку в одну минуту.
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
conference = await service.resolve(q) conference = await service.resolve(q)
if conference is None: if conference is None:
# Жёсткий счётчик — только на промахи: перебор номера конференции
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
await enforce_rate_limit(
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended": if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status) return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
@@ -154,11 +167,15 @@ async def guest_join_conference(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut: ) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
try: try:
return await service.join_as_guest(conference_id, data=data) return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc: except ConferenceNotFoundError as exc:
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc ) from exc
@@ -169,6 +186,9 @@ async def guest_join_conference(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required" status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc ) from exc
except InvalidPasswordError as exc: except InvalidPasswordError as exc:
# Подбор пароля закрытой конференции — тот же класс атаки, что и
# перебор номера, поэтому считается жёстким счётчиком.
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc ) from exc
@@ -284,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
return value.astimezone(UTC) return value.astimezone(UTC)
def _client_ip(request: Request) -> str: async def _count_guest_join_miss(ip: str) -> None:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю).""" """Учесть неудачную попытку гостевого входа в жёстком счётчике.
return request.client.host if request.client else "unknown"
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
(несуществующая конференция и неверный пароль) и обязано бросать 429
ровно так же, как обычный `enforce_rate_limit`.
"""
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)

View File

@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только """Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`).""" владельцу конференции (переопределяется на уровне `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): class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов """Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
# адреса) — см. `services/instance_settings.py`, `services/email.py`. # адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False contact_email_enabled: bool = False
contact_email: str | None = None contact_email: str | None = None
# Потолок качества публикации + максимум плиток сцены — см.
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
# не только в админке — настройка должна быть на руках у клиента до
# публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)

View File

@@ -3,15 +3,59 @@
Используется резолвом конференций и гостевым входом (`api/conferences.py`) — Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). (см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
## Два счётчика вместо одного (0.0.18)
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
конференцию» — при том что конференция существовала и была активна.
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
поток промахов; легитимный участник открывает существующую ссылку и получает
успех. Поэтому:
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
найдена, неверный пароль). Именно он защищает от перебора, и он остался
прежним — 10/мин;
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
человек из офиса за общим NAT спокойно зашла в одну конференцию.
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, Request, status
from core.redis import redis_client from core.redis import redis_client
RATE_LIMIT_MAX_REQUESTS = 10 RATE_LIMIT_MAX_REQUESTS = 10
RATE_LIMIT_WINDOW_SECONDS = 60 RATE_LIMIT_WINDOW_SECONDS = 60
# Промахи: перебор номера/ссылки или подбор пароля конференции.
RATE_LIMIT_MISS_MAX_REQUESTS = 10
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
# заведомо выше правдоподобного числа участников одной конференции.
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
def client_ip(request: Request) -> str:
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
клиентом. Первый элемент там подделывается одним заголовком, и лимит
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
async def enforce_rate_limit( async def enforce_rate_limit(
key: str, key: str,

View File

@@ -6,7 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field 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 schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus from services.ai_levels import AiLevelStatus
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
registration_email_domains: list[str] = Field(default_factory=list) registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool contact_email_enabled: bool
contact_email: str | None = None contact_email: str | None = None
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class TestEmailIn(BaseModel): 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 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 schemas.room_events import ForcedMuteSource
from services.recurrence import RecurrenceRule from services.recurrence import RecurrenceRule
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
class ConferenceOut(BaseModel): class ConferenceOut(BaseModel):

View File

@@ -9,6 +9,7 @@
import json import json
from core.config import get_settings from core.config import get_settings
from core.plugins.config import PublishQualityCap, StageMaxTiles
from core.security import verify_password from core.security import verify_password
from models.conference import Conference from models.conference import Conference
from schemas.conferences import JoinOut from schemas.conferences import JoinOut
@@ -52,15 +53,18 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None, avatar_url: str | None = None,
is_organizer: bool = False, is_organizer: bool = False,
) -> JoinOut: ) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`: `chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
читается здесь параметром, а не заново из БД, чтобы не плодить стороной значения `instance_settings`: читаются здесь параметрами, а не
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer` заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор — поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше). получают токен вовсе без метаданных, как и раньше).
@@ -87,4 +91,6 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, 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 join = None
if is_instant: if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
join = build_join( join = build_join(
conference, conference,
identity=str(owner_id), identity=str(owner_id),
name=owner_name, 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), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True, is_organizer=True,
) )
@@ -243,12 +245,14 @@ class ConferenceService:
"""Войти в конференцию зарегистрированным пользователем.""" """Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id) conference = await self._get_or_raise(conference_id)
await ensure_joinable(conference, password=password) await ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=str(user.id), identity=str(user.id),
name=user.name_user, 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), 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, 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.flush()
await self._session.commit() await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, 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( async def mute_participant(

View File

@@ -2,7 +2,8 @@
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `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`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
@@ -28,7 +29,10 @@ from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig,
PluginsConfig, PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig, SummarizerConfig,
SummaryRecipientsMode, SummaryRecipientsMode,
TranscriberConfig, TranscriberConfig,
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email" _KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
для обратной совместимости с уже развёрнутыми инстансами; при первом же для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`).""" `update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None} _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 registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None contact_email_enabled: bool | None = None
contact_email: str | None = None contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -340,6 +349,20 @@ class InstanceSettingsService:
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json")) await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.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() await self._session.commit()
return cfg return cfg
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"enabled", False "enabled", False
), ),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"), 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 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"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"] assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"] 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( async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -573,17 +577,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
assert response.json()["detail"] == "not_found" assert response.json()["detail"] == "not_found"
async def test_resolve_is_rate_limited_after_10_requests_per_minute( def _ip_headers() -> dict[str, str]:
client: httpx.AsyncClient, """Уникальный `X-Real-IP` на каждый тест.
) -> None:
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
"""
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) response = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert response.status_code == 404 assert response.status_code == 404
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) limited = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert limited.status_code == 429 assert limited.status_code == 429
async def test_successful_resolves_are_not_limited_by_miss_counter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
для существующей и активной конференции.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for _ in range(50):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
)
assert response.status_code == 200, response.text
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
работать. До исправления ключ был общим на весь инстанс.
"""
first, second = _ip_headers(), _ip_headers()
for _ in range(11):
await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
exhausted = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
assert exhausted.status_code == 429
other = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
)
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем --------------------------------------- # --- Вход зарегистрированным пользователем ---------------------------------------
@@ -783,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
assert guests[0].email == "alice-guest@example.com" 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( async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -835,21 +924,69 @@ async def test_guest_join_ended_conference_returns_410(
assert response.json()["detail"] == "conference_ended" assert response.json()["detail"] == "conference_ended"
async def test_guest_join_is_rate_limited_after_10_requests_per_minute( async def test_guest_join_allows_a_whole_conference_to_enter(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""Успешные гостевые входы не упираются в лимит промахов.
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
пускало внутрь именно это ограничение — счётчик не различал легитимный
массовый вход и перебор.
"""
conference = await _make_conference(db_session) conference = await _make_conference(db_session)
await db_session.commit() await db_session.commit()
headers = _ip_headers()
for i in range(30):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": f"Guest {i}"},
headers=headers,
)
assert response.status_code == 200, response.text
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
headers = _ip_headers()
missing_id = uuid.uuid4()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert response.status_code == 404
limited = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert limited.status_code == 429
async def test_guest_join_wrong_password_is_rate_limited(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
conference = await _make_conference(db_session, is_closed=True, password="right-password")
await db_session.commit()
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.post( response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Repeat Guest"}, json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert response.status_code == 200 assert response.status_code == 403
limited = await client.post( limited = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"} f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert limited.status_code == 429 assert limited.status_code == 429

View File

@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
) )
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" 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.registration_email_domains == []
assert cfg.contact_email_enabled is False assert cfg.contact_email_enabled is False
assert cfg.contact_email is None 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( 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 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( async def test_update_rejects_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -12,8 +12,8 @@
# затрутся при следующем рендере. # затрутся при следующем рендере.
listening-port=3478 listening-port=3478
# Установить 5349 в проде для TURN over TLS, если будут смонтированы # TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже). # заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
tls-listening-port=5349 tls-listening-port=5349
# Диапазон relay-портов для TURN-аллокаций. # Диапазон relay-портов для TURN-аллокаций.
@@ -31,14 +31,30 @@ fingerprint
# Без CLI/telnet admin-интерфейса в этой поставке. # Без CLI/telnet admin-интерфейса в этой поставке.
no-cli no-cli
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить # Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# TURN over TLS на 443: # render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
# cert=/etc/coturn/certs/cert.pem # скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
# pkey=/etc/coturn/certs/key.pem # Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком —
# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк.
# BEGIN-TLS-CERT
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/certs/key.pem
# END-TLS-CERT
log-file=stdout log-file=stdout
simple-log simple-log
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
# печатает по сессии: create/delete allocation, create permission, refresh —
# достаточно, чтобы дальше проверять относительно скромный вывод.
verbose
# Внешний IP сервера — обязателен для клиентов вне docker-сети # Внешний IP сервера — обязателен для клиентов вне docker-сети
# (network_mode: host здесь не даёт coturn определить публичный IP # (network_mode: host здесь не даёт coturn определить публичный IP
# автоматически). Для локальной разработки (без внешних участников) # автоматически). Для локальной разработки (без внешних участников)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.17} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.25}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -407,16 +407,19 @@ services:
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
# публичными. # публичными.
ports: ports:
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling - "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback - "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop. # мультиплексирует все ICE-сессии через него (rtc.udp_port в
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+) # livekit.yaml.template), а не открывает по порту на участника.
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения. # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
- "54000-54100:54000-54100/udp" # WebRTC media (ICE) # 101 порт держали 101 лишний userland-процесс на медиапути.
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
# macOS заняты Steam/системными процессами и эфемерными QUIC.
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -429,6 +432,49 @@ services:
profiles: ["media"] profiles: ["media"]
logging: *default-logging logging: *default-logging
# Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
# НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
# сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
# официального образа nginx, который стартует entrypoint от root и только
# nginx-воркеры позже понижают права по директиве в конфиге — см.
# deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
# сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
# никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
#
# Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
# в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
# /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
# fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
# оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
# открыта, чтобы её прочитал nobody:nogroup внутри coturn.
#
# Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
# сертификатов) — команда ниже просто ничего не копирует и завершается
# успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
# render-templates.sh — вторая половина того же переключателя).
coturn-certs-init:
image: busybox:1.36
command: >
sh -c '
SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
cp "$$SRC/fullchain.pem" /certs/cert.pem;
cp "$$SRC/privkey.pem" /certs/key.pem;
chmod 644 /certs/cert.pem /certs/key.pem;
echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
else
echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
fi
'
environment:
NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- coturn-certs:/certs
restart: "no"
profiles: ["media"]
logging: *default-logging
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
restart: unless-stopped restart: unless-stopped
@@ -441,6 +487,10 @@ services:
# (deploy/render-templates.sh, вызывается install.sh). # (deploy/render-templates.sh, вызывается install.sh).
volumes: volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
- coturn-certs:/etc/coturn/certs:ro
depends_on:
coturn-certs-init:
condition: service_completed_successfully
network_mode: host network_mode: host
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
# curl/wget, поэтому проверка процесса по имени не работает # curl/wget, поэтому проверка процесса по имени не работает
@@ -884,6 +934,11 @@ volumes:
# Загруженные пользователями файлы (аватары) — общий том между # Загруженные пользователями файлы (аватары) — общий том между
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`). # backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
media: media:
# Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
# (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
# coturn-certs-init выше. Обновляется при каждом перезапуске
# coturn-certs-init (deploy-hook certbot делает это при продлении).
coturn-certs:
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера. # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
prometheus_data: prometheus_data:
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание. # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.

View File

@@ -15,16 +15,20 @@ port: 7880
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде # Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
# расширить и синхронизировать с пробросом портов. # Раньше здесь был port_range_start/port_range_end (54000-54100) — под
port_range_start: 54000 # каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
port_range_end: 54100 # (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 — режим для локальной # use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
# разработки (Docker Desktop): use_external_ip=true определяет публичный # разработки (Docker Desktop): use_external_ip=true определяет публичный
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста # IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит # внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты # ("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/домен # В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу — # сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
@@ -50,10 +54,24 @@ rtc:
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh). # оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API. # Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
# #
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не # UDP и TCP на 3478 — оба порта уже открыты в ufw.
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс #
# неработающего `turns:` заставил бы клиента впустую ждать таймаута, # TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# прежде чем перейти к рабочему кандидату. # render-templates.sh) — до тех пор блок между маркерами вырезается
# целиком, и клиент его не увидит вовсе. Это осознанно: анонс
# неработающего `turns:` (без смонтированных в coturn сертификатов)
# заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти
# к рабочему кандидату — именно так это и стояло здесь до включения TLS.
#
# Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp
# выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста,
# а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host
# TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё
# один вариант «coturn healthy, но relay не работает».
#
# TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку,
# а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не
# выбираться первой.
turn_servers: turn_servers:
- host: ${TURN_EXTERNAL_IP} - host: ${TURN_EXTERNAL_IP}
port: 3478 port: 3478
@@ -65,6 +83,13 @@ rtc:
protocol: tcp protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET} secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400 ttl: 14400
# BEGIN-TLS-TURN
- host: ${TURN_TLS_HOST}
port: 5349
protocol: tls
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
# END-TLS-TURN
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует # Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей; # его как pub/sub и key-value хранилище состояния запущенных записей;

View File

@@ -9,27 +9,57 @@
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env, # запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
# влияющего на эти конфиги) — из корня репозитория: # влияющего на эти конфиги) — из корня репозитория:
# ./deploy/render-templates.sh # ./deploy/render-templates.sh
#
# Источник значений можно подменить переменной ENV_FILE:
# ENV_FILE=.env.local ./deploy/render-templates.sh
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
# через envsubst — расхождение с реальным рендером ждало своего часа.
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
# экспортирует, так что она сюда не протекает).
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env" # Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
# из корня репозитория), абсолютный — как есть.
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2 echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
exit 1 exit 1
fi fi
echo "[render] источник значений: $ENV_FILE"
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит # Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` — # значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис # валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
# (интерпретируется как редирект) при попытке `source` файла целиком. # (интерпретируется как редирект) при попытке `source` файла целиком.
#
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
# существующих .env).
env_var() { env_var() {
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
} }
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)" TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
TURN_REALM="$(env_var TURN_REALM)" TURN_REALM="$(env_var TURN_REALM)"
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)" TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём
# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS
# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) —
# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST
# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию
# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template).
TURN_TLS_HOST="$(env_var TURN_TLS_HOST)"
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)" LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)" LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)" LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
@@ -43,17 +73,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}" : "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}" : "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
# Вырезает блок между парой маркеров-комментариев (не только их самих), если
# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов,
# а не просто закомментированные "на будущее" строки.
strip_tls_block_if_disabled() {
local file="$1" begin_marker="$2" end_marker="$3"
if [ -z "$TURN_TLS_HOST" ]; then
sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak"
else
sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak"
fi
}
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf" < "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
echo "[render] deploy/coturn/turnserver.conf готов" strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT"
echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")"
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit # TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет # анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `static-auth-secret` в turnserver.conf выше. # обязан совпадать с `static-auth-secret` в turnserver.conf выше.
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \ envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
echo "[render] deploy/livekit/livekit.yaml готов" echo "[render] deploy/livekit/livekit.yaml готов"
envsubst '${REDIS_PASSWORD}' \ envsubst '${REDIS_PASSWORD}' \

View File

@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
"registration_team_choice": false, "registration_team_choice": false,
"registration_email_domain_enabled": false, "registration_email_domain_enabled": false,
"registration_email_domain": null, "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_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена - `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже) - `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 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media) 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. Нужны ОБА пункта: # TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из # сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но # deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
@@ -191,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic + собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`). seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него **Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого `deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
@@ -237,7 +245,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
```bash ```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF' cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash #!/bin/bash
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
# ни для того, ни для другого — нужен restart.
docker restart vidconf-nginx-1 docker restart vidconf-nginx-1
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
cd /opt/vidconf || exit 1
if grep -qE '^TURN_TLS_HOST=.+' .env; then
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
docker restart vidconf-coturn-1
fi
EOF EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
@@ -276,8 +299,9 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
# 1. Все сервисы healthy # 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100, # 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp, если включили TURN — раздел 8) # плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
# если включили TURN over TLS — раздел 8)
ss -ltnp ss -ltnp
# 3. Redis требует пароль (НЕ должен пускать без него) # 3. Redis требует пароль (НЕ должен пускать без него)
@@ -318,7 +342,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в **Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1, `LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение жёстким корпоративным firewall, которые не могут установить медиа-соединение
@@ -358,15 +382,107 @@ state: connected`, но собеседник не видит видео/не с
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`. `docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли — Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`. смотрите ufw и `TURN_EXTERNAL_IP`.
⚠️ **Эта проверка работает только с `verbose` в
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
этого флага coturn пишет в лог только служебные строки старта
(листенеры, `Total auth threads`) и НИКОГДА не логирует
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
и `verbose` в нём нет — добавьте флаг, перерендерите
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
доверять этой проверке.
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный ### TURN over TLS (порт 5349)
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
`deploy/coturn/turnserver.conf.template`, добавить volume с только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы **443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
клиента ждать таймаута перед переходом к рабочему кандидату. занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
`network_mode: host`оба не могут забрать один и тот же порт без
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
сложная система; в этом проекте её нет, и заводить её только ради 443 не
оправдано, пока 5349 проходит через те же firewall, что и 443.
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
```bash
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
# NGINX_CERT_NAME:
TURN_TLS_HOST=vidconf.ru
```
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
один вариант «coturn healthy, а relay не работает», только на новом порту.
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
выполняется от root) физически не может прочитать
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
отработает.
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
копии, которые перечитываются только при (пере)старте контейнера — reload
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
тихо остановится обслуживать новые TLS-хендшейки примерно через два
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
действия, и клиенты начнут получать ошибку валидации сертификата при
попытке TLS-хендшейка.
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
непредсказуемое время суток).
Порядок включения:
1. `TURN_TLS_HOST=<домен>` в `.env`.
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
`livekit.yaml` с TLS-блоками).
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
4. `docker compose … up -d --force-recreate coturn-certs-init coturn`
пересоздать (не просто restart: новый volume/depends_on).
5. Проверить, что TLS реально отвечает:
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
соединения.
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
`certbot renew --dry-run`.
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
transport policy `relay` в браузере), и убедиться, что аллокации в
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
обычный TURN на 3478 продолжает работать для остальных клиентов
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
отдельной строкой `ALLOCATE processed, success` — можно отличить
TLS-сессию от обычной по времени и по тому, что порт входящего
соединения — 5349).
--- ---

View File

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

View File

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

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`. * конверте пагинации `items`/`total`.
*/ */
import { apiRequest } from '@/api/client' import { apiRequest } from '@/api/client'
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences' import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
/** Уровень качества AI-обработки (транскрибация + суммаризация). */ /** Уровень качества AI-обработки (транскрибация + суммаризация). */
export type AiLevel = 'min' | 'medium' | 'max' export type AiLevel = 'min' | 'medium' | 'max'
@@ -39,6 +39,10 @@ export interface SettingsOut {
contact_email_enabled: boolean contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */ /** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | 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. */ /** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean contact_email_enabled?: boolean
contact_email?: string | null contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
} }
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ /** Тело запроса тестовой отправки письма (`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' 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-моделью * Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там). * `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
} }
/** /**

View File

@@ -10,7 +10,7 @@ import {
type SettingsUpdateIn, type SettingsUpdateIn,
type TestEmailOut, type TestEmailOut,
} from '@/api/admin' } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences' import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'owner', label: 'Только организатору' }, { 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> = { const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)', min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний', medium: 'Средний',
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [newDomainInput, setNewDomainInput] = useState('') const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled) const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '') 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 [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null) const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (trimmedContactEmail !== (data.contact_email ?? null)) { if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail 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) mutation.mutate(payload)
} }
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </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"> <section className="settings-card">
<h2>Контактный адрес</h2> <h2>Контактный адрес</h2>
<p className="desc"> <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 = [ const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎', '🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
] ]
interface ChatPanelProps { 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

@@ -126,8 +126,10 @@ function TileBody({
const showSharingChip = Boolean( const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal, onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
) )
// Кнопка закрепления — только там, где сцена умеет закрепление (основное // Кнопка закрепления — только там, где сцена её даёт (основное окно передаёт
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего). // `onTogglePin`; в мини-плеере своего тулбара нет и плитка одна, поэтому
// булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25
// действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки // Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать. // рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference) const tileKey = stageTrackKey(trackReference)
@@ -200,10 +202,8 @@ function TileBody({
type="button" type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`} className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned} aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'} title={isPinned ? 'Открепить' : 'Закрепить'}
aria-label={ aria-label={isPinned ? `Открепить: ${displayName}` : `Закрепить: ${displayName}`}
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
onClick={(e) => { onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick` // Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке». // у `ParticipantTile`) — булавка не должна означать «клик по плитке».

View File

@@ -4,7 +4,6 @@ import { Track, type Participant } from 'livekit-client'
import { import {
CarouselLayout, CarouselLayout,
FocusLayoutContainer, FocusLayoutContainer,
RoomAudioRenderer,
isTrackReference, isTrackReference,
useRoomContext, useRoomContext,
useSpeakingParticipants, useSpeakingParticipants,
@@ -44,9 +43,11 @@ const STAGE_TRACK_SOURCES = [
] ]
/** /**
* Удержание фокуса основного окна при смене говорящего, мс. * Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах
* сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не
* было вовсе, фокус там переключался мгновенно).
* *
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без * Сцена следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на * удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants` * секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но * поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
@@ -59,6 +60,10 @@ const STAGE_TRACK_SOURCES = [
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз. * фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно * Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика. * запаздывает переход на нового докладчика.
*
* В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное
* переключение читается не как «камера следует за разговором», а как мигание
* всего окна целиком.
*/ */
const SPEAKER_HOLD_MS = 1200 const SPEAKER_HOLD_MS = 1200
@@ -69,8 +74,9 @@ const SPEAKER_HOLD_MS = 1200
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение * применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим). * сравнивается по ссылке с текущим).
* *
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там * `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим
* фокус обязан следовать за говорящим мгновенно, поведение не менялось). * режимом никто не пользуется (обе сцены удерживают состав), но параметр
* оставлен: он и делает функцию пригодной для повторного использования.
*/ */
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] { function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers) const [steady, setSteady] = useState(speakers)
@@ -148,9 +154,9 @@ function PipMicToggle() {
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован — * `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
* см. `StageViewOptions`. * см. `StageViewOptions`.
* *
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ * ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере —
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он * ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и * фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно * `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari * открывалось на самом пользователе вместо того, что он видел крупно. В Safari
* бага не было видно: там Document PiP не используется, а video-фолбэк * бага не было видно: там Document PiP не используется, а video-фолбэк
@@ -159,6 +165,13 @@ function PipMicToggle() {
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны * `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
* — возврат из мини-плеера тоже не сбрасывает фокус. * — возврат из мини-плеера тоже не сбрасывает фокус.
* *
* Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` /
* `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и
* закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе.
* Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый
* момент ровно один (пока открыт Document PiP, основное окно показывает
* заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете.
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не * Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая * горизонтальная лента, см. design/mockups/room.html после правки: узкая
* колонка сбоку, скролл по вертикали). Это штатное поведение самого * колонка сбоку, скролл по вертикали). Это штатное поведение самого
@@ -188,12 +201,21 @@ function PipMicToggle() {
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида; * показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе. * режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
* *
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у * ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному: * намеренно «упрощён»: без удержания говорящего, без удержания демонстрации
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно * экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из * без закрепления. На практике это читалось как поломка: в мини-окне
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление * демонстрация экрана слетала от любой чужой реплики, а закрепление,
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке. * сделанное в основном окне, не действовало. Теперь `pickStageFocus`
* получает одни и те же правила независимо от варианта — разным остаётся
* ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо
* раскладки.
*
* Единственное сознательное отличие — `localKey`: у мини-плеера есть
* последний фолбэк «показать себя», у основного окна его нет (там фолбэк —
* первый трек по порядку, поведение не менялось). Строка из того же сюжета,
* что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате
* выбирало произвольного участника.
*/ */
export function RoomStage({ export function RoomStage({
variant = 'full', variant = 'full',
@@ -203,9 +225,12 @@ export function RoomStage({
onHideOthers, onHideOthers,
initialFocusKey = null, initialFocusKey = null,
onFocusKeyChange, onFocusKeyChange,
initialPinnedKey = null,
onPinnedKeyChange,
onPinFocus, onPinFocus,
raisedHandIdentities, raisedHandIdentities,
conferenceId, conferenceId,
stageMaxTiles,
}: { }: {
variant?: 'full' | 'pip' variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */ /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -220,6 +245,10 @@ export function RoomStage({
initialFocusKey?: string | null initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */ /** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void onFocusKeyChange?: (key: string | null) => void
/** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */
initialPinnedKey?: string | null
/** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */
onPinnedKeyChange?: (key: string | null) => void
/** /**
* Участника только что закрепили (не открепили) в режиме без крупной * Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`), * плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
@@ -232,6 +261,8 @@ export function RoomStage({
raisedHandIdentities?: Set<string> raisedHandIdentities?: Set<string>
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */ /** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
conferenceId?: string conferenceId?: string
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
stageMaxTiles?: number
}) { }) {
const room = useRoomContext() const room = useRoomContext()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
@@ -242,9 +273,9 @@ export function RoomStage({
// (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`, // (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не // событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`). // дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers` // Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и
// и `SPEAKER_HOLD_MS`), PiP берёт значение как есть. // `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS) const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera) const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare) const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -286,8 +317,10 @@ export function RoomStage({
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey) const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source` // Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет. // плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего. // Стартовое значение приходит от предыдущего экземпляра сцены (тот же
const [pinnedKey, setPinnedKey] = useState<string | null>(null) // мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное
// в основном окне, действует и в мини-плеере.
const [pinnedKey, setPinnedKey] = useState<string | null>(initialPinnedKey)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null) const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey) const cameraKeys = cameraTracks.map(stageTrackKey)
@@ -296,13 +329,23 @@ export function RoomStage({
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди // камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном // демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе. // состоянии и булавка не «висела» на исчезнувшем ключе.
//
// `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере
// нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав
// приезжает следующим рендером, из подписки на события комнаты). Без этой
// проверки пустой набор читается как «все вышли», и закрепление, приехавшее
// через `initialPinnedKey`, обнулялось сразу при монтировании — то есть
// мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25).
// Пустых наборов при живой комнате не бывает: камера есть у каждого
// участника хотя бы плейсхолдером.
const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey)) const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks const tracksChanged = tracks !== prevTracks
const speakingChanged = speakingParticipants !== prevSpeakingParticipants const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey const pinnedChanged = pinnedKey !== prevPinnedKey
if (pinnedKey !== null && !pinnedAlive) { if (pinnedKey !== null && tracksKnown && !pinnedAlive) {
setPinnedKey(null) setPinnedKey(null)
} }
@@ -325,14 +368,12 @@ export function RoomStage({
cameraKeys, cameraKeys,
screenShareKeys, screenShareKeys,
speakingCameraKeys, speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
prevKeys, prevKeys,
prevFocusKey: focusKey, prevFocusKey: focusKey,
pinnedKey: pinnedAlive ? pinnedKey : null, pinnedKey: pinnedAlive ? pinnedKey : null,
followSpeaker: true, followSpeaker: true,
holdScreenShare: variant !== 'pip', holdScreenShare: true,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся. // Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null, localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
}) })
@@ -349,6 +390,11 @@ export function RoomStage({
onFocusKeyChange?.(focusKey) onFocusKeyChange?.(focusKey)
}, [focusKey, onFocusKeyChange]) }, [focusKey, onFocusKeyChange])
// То же самое для закрепления — см. `initialPinnedKey`.
useEffect(() => {
onPinnedKeyChange?.(pinnedKey)
}, [pinnedKey, onPinnedKeyChange])
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0] const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И // При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
@@ -391,13 +437,16 @@ export function RoomStage({
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/ // Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`, // грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus` // `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть. // и по тем же правилам, что и в основном окне (закрепление, удержание
// демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`.
if (variant === 'pip') { if (variant === 'pip') {
return ( return (
<section className="stage room-single-tile"> <section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />} {focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<PipMicToggle /> <PipMicToggle />
<RoomAudioRenderer />
</section> </section>
) )
} }
@@ -429,14 +478,14 @@ export function RoomStage({
function renderMain(): ReactNode { function renderMain(): ReactNode {
if (effectiveMode === 'tiles') { if (effectiveMode === 'tiles') {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
} }
if (effectiveMode === 'live-tiles') { if (effectiveMode === 'live-tiles') {
return ( return (
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}> <StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
@@ -445,7 +494,7 @@ export function RoomStage({
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка. // прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
if (sideTracks.length === 0 && !hideOthers) { if (sideTracks.length === 0 && !hideOthers) {
return ( return (
<StageGrid tracks={tracks}> <StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
<RoomParticipantTile {...tileProps} /> <RoomParticipantTile {...tileProps} />
</StageGrid> </StageGrid>
) )
@@ -493,7 +542,6 @@ export function RoomStage({
<span>Показать остальных ({sideTracks.length})</span> <span>Показать остальных ({sideTracks.length})</span>
</button> </button>
)} )}
<RoomAudioRenderer />
</section> </section>
) )
} }

View File

@@ -1,6 +1,5 @@
import { import {
Hand, Hand,
ListOrdered,
LogOut, LogOut,
Maximize, Maximize,
MessageSquare, MessageSquare,
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/** /**
* Опции захвата демонстрации экрана: `audio: true` — звук * Опции захвата демонстрации экрана: `audio: true` — звук
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
handQueue: HandQueueEntry[] handQueue: HandQueueEntry[]
onRaiseHand: () => void onRaiseHand: () => void
onLowerHand: () => void onLowerHand: () => void
handQueueOpen: boolean /** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onToggleHandQueue: () => void onLowerHandById: (identity: string) => void
} }
/** /**
@@ -104,8 +103,7 @@ export function RoomToolbar({
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
handQueueOpen, onLowerHandById,
onToggleHandQueue,
layoutMode, layoutMode,
onLayoutModeChange, onLayoutModeChange,
hideOthers, hideOthers,
@@ -113,7 +111,6 @@ export function RoomToolbar({
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
const mic = useTrackToggle({ source: Track.Source.Microphone }) const mic = useTrackToggle({ source: Track.Source.Microphone })
@@ -197,23 +194,7 @@ export function RoomToolbar({
<span className="label">Рука</span> <span className="label">Рука</span>
</button> </button>
{isOrganizer && ( <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
<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>
)}
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu
@@ -266,7 +247,14 @@ export function RoomToolbar({
<span className="icon-shell"> <span className="icon-shell">
<PictureInPicture2 className="lucide" aria-hidden="true" /> <PictureInPicture2 className="lucide" aria-hidden="true" />
</span> </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> </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 { ChevronLeft, ChevronRight } from 'lucide-react'
import { import {
TrackLoop, TrackLoop,
@@ -47,6 +47,17 @@ interface StageGridProps {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */ /** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
children: ReactNode 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` (кнопки со стрелками + счётчик, доступен и мышью, и с * `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
* клавиатуры; на тач-экране страницы листаются ещё и свайпом). * клавиатуры; на тач-экране страницы листаются ещё и свайпом).
*/ */
export function StageGrid({ tracks, children }: StageGridProps) { export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
const gridEl = useRef<HTMLDivElement | null>(null) const gridEl = useRef<HTMLDivElement | null>(null)
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где // Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
// `current` был readonly и тип вёл себя ковариантно). В типах React 19 // `current` был readonly и тип вёл себя ковариантно). В типах React 19
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
// параметр уже не присваивается — приведение безопасно: оба хука только // параметр уже не присваивается — приведение безопасно: оба хука только
// читают `.current` (ResizeObserver и слушатели touch-событий). // читают `.current` (ResizeObserver и слушатели touch-событий).
const gridRef = gridEl as RefObject<HTMLDivElement> 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) const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridRef, { useSwipe(gridRef, {

View File

@@ -62,8 +62,10 @@ export interface PickStageFocusInput {
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */ /** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null prevFocusKey: string | null
/** /**
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на * Ключ трека, ЗАКРЕПЛЁННОГО пользователем (кнопка-булавка на плитке
* плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет. * основного окна; состояние живёт в `RoomStage.tsx` и переезжает в
* мини-плеер через `RoomPage`, см. там `initialPinnedKey`); `null` —
* закрепления нет.
* Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной * Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* демонстрации экрана (формулировка оператора: «перебивается только чьей-либо * демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* демонстрацией экрана») — а когда демонстрация закончилась, фокус * демонстрацией экрана») — а когда демонстрация закончилась, фокус
@@ -79,19 +81,22 @@ export interface PickStageFocusInput {
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для * не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от * основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled * дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в * `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged` плюс
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в * удержание в ~1.2 с (см. `useSteadySpeakers` в `RoomStage.tsx`, с 0.0.25 —
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`. * в обеих сценах), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5). * По умолчанию `false` — фокус удерживается (см. правило 5).
*/ */
followSpeaker?: boolean followSpeaker?: boolean
/** /**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику * Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный * (правило 3). Демонстрация — это содержательный центр разговора, и уводить
* центр разговора, и уводить её из большого окна на каждую реплику нельзя. * её из фокуса на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе *
* — всегда показывает того, кто говорит, поэтому там `false` (поведение * С 0.0.25 включено в ОБЕИХ сценах. До этого мини-плеер (PiP) намеренно
* PiP не менялось с 0.0.4). * ходил с `false` — «одна плитка, всегда показываем говорящего»; на практике
* это выглядело как поломка: демонстрация в мини-окне пропадала, стоило
* кому-то сказать слово. По умолчанию всё ещё `false` — это поведение
* функции без явного запроса удержания.
*/ */
holdScreenShare?: boolean holdScreenShare?: boolean
/** /**
@@ -132,16 +137,14 @@ function pickSpeakerKey(
* фокус безусловно переходит на него (последний из новых, если появилось * фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая * сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet). * демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник * 2. Закрепление (`pinnedKey`): закреплённый участник
* забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает * забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше * ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* удержания (правило 5): как только демонстрация закончилась и * удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не * `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации. * остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в * 3. `holdScreenShare`: демонстрация, уже стоящая в фокусе, не уступает
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на * заговорившему — иначе окно уводило бы шэр на каждую реплику.
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на * 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих * него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`). * предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).

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, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
}, },
}) })
} catch (err) { } catch (err) {

View File

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

View File

@@ -2,11 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { PictureInPicture2 } from 'lucide-react' import { PictureInPicture2 } from 'lucide-react'
import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react' import { LiveKitRoom, RoomAudioRenderer, usePersistentUserChoices } from '@livekit/components-react'
import type { RoomOptions } from 'livekit-client' import type { RoomOptions } from 'livekit-client'
import '@livekit/components-styles' import '@livekit/components-styles'
import '@/styles/room.css' 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 { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
@@ -16,10 +16,10 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage' import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar' import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel' import { ChatPanel } from '@/components/room/ChatPanel'
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher' import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
interface RoomJoinState { interface RoomJoinState {
@@ -32,6 +32,18 @@ interface RoomJoinState {
number?: string number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */ /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean 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, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles,
}) })
} }
} catch (err) { } catch (err) {
@@ -147,11 +161,6 @@ export function RoomPage() {
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте). // изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
const [handQueueOpen, setHandQueueOpen] = useState(false)
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()` // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`). // на каждой плитке сцены (см. `RoomParticipantTile`).
const raisedHandIdentities = useMemo( const raisedHandIdentities = useMemo(
@@ -199,6 +208,12 @@ export function RoomPage() {
// открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`. // открывал мини-окно на самом пользователе. Подробнее — докстринг `RoomStage`.
const [stageFocusKey, setStageFocusKey] = useState<string | null>(null) const [stageFocusKey, setStageFocusKey] = useState<string | null>(null)
// Закрепление участника (булавка на плитке) — по той же причине и тем же
// мостиком, что и `stageFocusKey`: экземпляр `RoomStage` при открытии
// мини-плеера пересоздаётся, и до 0.0.25 закрепление, сделанное в основном
// окне, в мини-окно не попадало вовсе (там был свой чистый `useState`).
const [stagePinnedKey, setStagePinnedKey] = useState<string | null>(null)
// Сохранённый выбор устройств — читаем через собственный вызов // Сохранённый выбор устройств — читаем через собственный вызов
// usePersistentUserChoices (независимый от того, что использует // usePersistentUserChoices (независимый от того, что использует
// DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО: // DeviceSettingsDialog: там свой вызов хука со своим состоянием). ВАЖНО:
@@ -208,9 +223,16 @@ export function RoomPage() {
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ // потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого // вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
// не делаем (сохранение — только в DeviceSettingsDialog). // не делаем (сохранение — только в DeviceSettingsDialog).
//
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
// подключённого участника.
const { userChoices } = usePersistentUserChoices() const { userChoices } = usePersistentUserChoices()
const roomOptions = useMemo<RoomOptions>( const roomOptions = useMemo<RoomOptions>(
() => ({ () => ({
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент // Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера // подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто // плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
@@ -236,7 +258,7 @@ export function RoomPage() {
// (setActiveMediaDevice), а не пересозданием roomOptions. // (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined }, audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}), }),
[userChoices], [userChoices, joinState?.publishQualityCap],
) )
if (error) { if (error) {
@@ -269,6 +291,21 @@ export function RoomPage() {
options={roomOptions} options={roomOptions}
onDisconnected={handleDisconnected} onDisconnected={handleDisconnected}
> >
{/* Звук комнаты рендерится ЗДЕСЬ, а не внутри `RoomStage`, и ровно
одним экземпляром на всю страницу. `RoomAudioRenderer` — это набор
скрытых `<audio>`, к которым LiveKit привязывает чужие аудиотреки
(`track.attach(el)`). Пока он жил в сцене, открытие мини-плеера
переносило эти элементы в ДРУГОЙ документ (Document PiP — отдельное
окно со своим `document`), а возврат — обратно, и после возврата
звук чужих участников пропадал: Chrome теряет аудиовыход у
remote-трека, переехавшего между документами. Замерено: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration`/`totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же
цикл detach/attach В ПРЕДЕЛАХ ОДНОГО документа безвреден — дело
именно в переезде. Здесь элементы живут в основном документе
непрерывно, весь цикл «открыл мини-окно → вернул» их не касается. */}
<RoomAudioRenderer />
<div data-lk-theme="default" className="room-shell"> <div data-lk-theme="default" className="room-shell">
<RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} /> <RoomTopbar title={joinState.title ?? null} slug={slug} number={joinState.number} />
<div className="room-main"> <div className="room-main">
@@ -290,9 +327,12 @@ export function RoomPage() {
onHideOthers={() => setHideOthers(true)} onHideOthers={() => setHideOthers(true)}
initialFocusKey={stageFocusKey} initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey} onFocusKeyChange={setStageFocusKey}
initialPinnedKey={stagePinnedKey}
onPinnedKeyChange={setStagePinnedKey}
onPinFocus={handlePinFocus} onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities} raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId} conferenceId={joinState.conferenceId}
stageMaxTiles={joinState.stageMaxTiles}
/> />
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (
@@ -304,13 +344,6 @@ export function RoomPage() {
onClose={() => setChatOpen(false)} onClose={() => setChatOpen(false)}
/> />
)} )}
{handQueueOpen && (
<HandQueuePanel
queue={chat.handQueue}
onLower={(identity) => chat.lowerHand(identity)}
onClose={() => setHandQueueOpen(false)}
/>
)}
</div> </div>
<RoomToolbar <RoomToolbar
chatVisible={chatVisible} chatVisible={chatVisible}
@@ -327,8 +360,7 @@ export function RoomPage() {
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}
handQueueOpen={handQueueOpen} onLowerHandById={(identity) => chat.lowerHand(identity)}
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
layoutMode={layoutMode} layoutMode={layoutMode}
onLayoutModeChange={handleLayoutModeChange} onLayoutModeChange={handleLayoutModeChange}
hideOthers={hideOthers} hideOthers={hideOthers}
@@ -349,10 +381,17 @@ export function RoomPage() {
`variant="pip"` — мини-плеер `variant="pip"` — мини-плеер
показывает только активное окно (одну плитку), без карусели/грида показывает только активное окно (одну плитку), без карусели/грида
основного окна. `initialFocusKey` — то, что было крупно в основном основного окна. `initialFocusKey` — то, что было крупно в основном
окне: без него мини-окно открывалось на самом пользователе. */} окне: без него мини-окно открывалось на самом пользователе;
`initialPinnedKey` — закрепление оттуда же. */}
{pip.pipWindow && {pip.pipWindow &&
createPortal( createPortal(
<RoomStage variant="pip" initialFocusKey={stageFocusKey} onFocusKeyChange={setStageFocusKey} />, <RoomStage
variant="pip"
initialFocusKey={stageFocusKey}
onFocusKeyChange={setStageFocusKey}
initialPinnedKey={stagePinnedKey}
onPinnedKeyChange={setStagePinnedKey}
/>,
pip.pipWindow.document.body, pip.pipWindow.document.body,
)} )}
<ForcedMuteWatcher event={chat.lastForcedMute} /> <ForcedMuteWatcher event={chat.lastForcedMute} />

View File

@@ -16,6 +16,11 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
/* Запрос по ширине САМОЙ панели, а не окна: `.layout` — flex 42/58, и на
широком окне с узкой панелью `vw` (см. .brand-headline) не отражает
реальную доступную ширину — тот же паттерн, что у `.room-tile-avatar`
(container-type:size + cqmin, room.css) для аватара участника. */
container-type: inline-size;
} }
.brand-panel::after { .brand-panel::after {
content: ""; content: "";
@@ -41,6 +46,12 @@
.brand-headline { .brand-headline {
font: var(--text-display-lg); font: var(--text-display-lg);
font-family: var(--font-display); font-family: var(--font-display);
/* `cqw` — от ширины `.brand-panel` (её `container-type: inline-size` выше),
а не окна: длинное слово «инфраструктура» иначе не помещается именно
тогда, когда окно широкое, а панель (42% от него) — узкая. Раньше кегль
уменьшался только в @media по ширине ОКНА (узкие экраны) — не спасало
от этого случая. */
font-size: clamp(20px, 8cqw, 34px);
color: var(--color-ink-700); color: var(--color-ink-700);
margin: 0 0 var(--space-4); margin: 0 0 var(--space-4);
max-width: 460px; max-width: 460px;
@@ -51,7 +62,11 @@
max-width: 420px; max-width: 420px;
margin: 0; margin: 0;
} }
.brand-stats { display: flex; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); } /* `flex-wrap` не только на мобильном медиа-запросе (см. ниже) — та же
природа бага, что у заголовка: узкая ПАНЕЛЬ (а не узкое окно) не даёт
двум плашкам поместиться в ряд, а `overflow:hidden` у `.brand-panel`
обрезал вторую вместо переноса. */
.brand-stats { display: flex; flex-wrap: wrap; gap: var(--space-4); z-index: 1; margin-top: var(--space-8); }
.stat-glass { .stat-glass {
background: var(--color-surface-glass); background: var(--color-surface-glass);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
@@ -248,10 +263,10 @@
right: -90px; right: -90px;
bottom: -90px; bottom: -90px;
} }
/* clamp() — на узких экранах слово «инфраструктура» иначе вылезает за /* Кегль теперь считает `.brand-headline` сама (cqw от ширины панели, см.
край брендовой панели (см. .brand-panel padding ниже и её overflow:hidden, базовое правило) — здесь снимаем только `max-width:460px`: в сложенной
обрезающий текст без переноса вместо уменьшения кегля). */ колонкой раскладке панель может стать шире 460px, а дизайн этого хочет. */
.brand-headline { max-width: none; font-size: clamp(22px, 6.2vw, 34px); } .brand-headline { max-width: none; }
.brand-sub { max-width: none; } .brand-sub { max-width: none; }
.brand-stats { flex-wrap: wrap; } .brand-stats { flex-wrap: wrap; }
.stat-glass { flex: 1 1 140px; min-width: 0; } .stat-glass { flex: 1 1 140px; min-width: 0; }

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; } .stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */ /* Нижний тулбар: свои кнопки на хуках 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 { .room-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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); background: var(--color-room-surface);
border-top: 1px solid var(--color-room-tile-border); 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; flex-shrink: 0;
} }
.tb-btn { .tb-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 4px; gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
background: transparent; background: transparent;
border: none; 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); border-radius: var(--radius-md);
color: var(--color-room-text-primary); color: var(--color-room-text-primary);
min-width: 76px; min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
cursor: pointer; cursor: pointer;
} }
.tb-btn .icon-shell { .tb-btn .icon-shell {
width: 48px; width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
height: 48px; height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
background: var(--color-room-tile); background: var(--color-room-tile);
color: var(--color-room-text-primary); 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); } .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 .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; } .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; } .chat-empty { font: var(--text-body); color: var(--color-room-text-tertiary); margin: auto; text-align: center; }
/* `min-height: 0` обязателен — тот же приём, что у `.stage-side` (комментарий
выше): без него `.chat-panel` (flex-колонка) в Firefox не сжимает
`.chat-messages` до высоты `flex:1`, а даёт ей вырасти по контенту
(список сообщений) и вылезти за пределы панели — Chrome в этой ситуации
более снисходителен, Firefox — нет. */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: var(--space-5); padding: var(--space-5);
display: flex; display: flex;
@@ -500,6 +546,13 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
.chat-input-row textarea { .chat-input-row textarea {
flex: 1; flex: 1;
/* Firefox даёт `<textarea>` большую автоматическую минимальную ширину,
завязанную на атрибут `cols` (умолчание 20 символов моноширинной
метрики), и как flex-item без `min-width:0` отказывается сжиматься
ниже нее — панель шириной 320px раздувается вправо. Chrome/Safari
считают минимальную ширину textarea мягче, поэтому баг был виден
только в Firefox. */
min-width: 0;
resize: none; resize: none;
background: var(--color-room-tile); background: var(--color-room-tile);
border: 1px solid var(--color-room-tile-border); border: 1px solid var(--color-room-tile-border);
@@ -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 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-wrap { position: relative; display: flex; flex-shrink: 0; }
.chat-emoji-trigger { .chat-emoji-wrap .chat-emoji-trigger {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%; border-radius: 50%;
@@ -539,35 +600,61 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
} }
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); } .chat-emoji-wrap .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-wrap .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:disabled { opacity: 0.5; cursor: default; }
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
неполной последней строки. `max-width` — страховка на случай совсем узкого
viewport: фикс-ширина 220px без потолка сама по себе не переполняется при
текущей раскладке (триггер у левого края панели, попап растёт вправо в
свободное место — проверено геометрией и вживую), но фиксированный размер
совсем без ограничителя — плохая практика сама по себе. */
.chat-emoji-popover { .chat-emoji-popover {
position: absolute; position: absolute;
bottom: calc(100% + var(--space-2)); bottom: calc(100% + var(--space-2));
left: 0; left: 0;
z-index: 50; z-index: 50;
width: 224px; width: 220px;
max-width: calc(100vw - 2 * var(--space-4));
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 2px; gap: 4px;
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-room-tile-border); border: 1px solid var(--color-room-tile-border);
background: var(--color-room-surface-raised); background: var(--color-room-surface-raised);
box-shadow: var(--shadow-room-panel); 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; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
padding: 6px; white-space: nowrap;
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; 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) { @media (max-width: 900px) {
.chat-panel { .chat-panel {
@@ -580,16 +667,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
} }
} }
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ---------- /* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список * Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
* — свой. */ * `.chat-panel`: очередь — короткий список, а не история переписки,
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
.hand-queue-menu { width: 300px; padding: var(--space-3); }
.hand-queue-list { .hand-queue-list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: var(--space-3); padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
/* Высота растёт вместе со списком (при 12 записях поповер компактный), но
не безгранично — после ~10 строк упирается в потолок и скроллится
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
max-height: 460px;
overflow-y: auto; overflow-y: auto;
} }
.hand-queue-item { .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_USE_EXTERNAL_IP "false"
ensure_default LIVEKIT_NODE_IP "127.0.0.1" ensure_default LIVEKIT_NODE_IP "127.0.0.1"
ensure_default TURN_EXTERNAL_IP "127.0.0.1" ensure_default TURN_EXTERNAL_IP "127.0.0.1"
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
# .env (для discoverability), а не отсутствует молча.
ensure_default TURN_TLS_HOST ""
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5 # Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004) # (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)