2 Commits

Author SHA1 Message Date
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
24 changed files with 410 additions and 26 deletions

View File

@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.20 VIDCONF_VERSION=0.0.21
# --- Профили 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,24 @@
Формат основан на [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.21] — 2026-08-02
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
### Добавлено
- Настройка инстанса «Потолок качества публикации видео» (без ограничения /
720p / 360p / 180p) — режет битрейт исходящего видео публикующего через
`publishDefaults` LiveKit, снижает нагрузку на его канал и устройство.
Дефолт — без ограничения, поведение существующих инсталляций не меняется.
- Настройка инстанса «Максимум плиток на экране» (25 / 16 / 9 / 4) — участники
сверх лимита уходят на следующую страницу сетки вместо подписки на их
видеотреки, меньше одновременных видеопотоков на канал и экран участника.
Дефолт — 25 (текущий максимум сетки 5×5), без изменений.
- Обе настройки доступны в новой карточке «Нагрузка» вкладки «Настройки»
админки и отдаются участнику вместе с токеном входа в конференцию — ещё до
подключения к комнате, чтобы применяться до публикации трека и не вызывать
переподключение уже вошедших участников при смене настройки.
## [0.0.20] — 2026-08-02 ## [0.0.20] — 2026-08-02
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт. Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.

View File

@@ -1 +1 @@
0.0.20 0.0.21

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

@@ -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

@@ -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(
@@ -841,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:

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

@@ -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.20} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.21}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул

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

@@ -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

@@ -206,6 +206,7 @@ export function RoomStage({
onPinFocus, onPinFocus,
raisedHandIdentities, raisedHandIdentities,
conferenceId, conferenceId,
stageMaxTiles,
}: { }: {
variant?: 'full' | 'pip' variant?: 'full' | 'pip'
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */ /** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
@@ -232,6 +233,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()
@@ -429,14 +432,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 +448,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>
) )

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

@@ -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

@@ -6,7 +6,7 @@ import { LiveKitRoom, 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'
@@ -19,6 +19,7 @@ import { ChatPanel } from '@/components/room/ChatPanel'
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 {
@@ -31,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
} }
/** /**
@@ -97,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) {
@@ -202,9 +217,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 по умолчанию выключены, и без них каждый клиент
// подписан на полное качество всех чужих треков независимо от размера // подписан на полное качество всех чужих треков независимо от размера
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто // плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
@@ -230,7 +252,7 @@ export function RoomPage() {
// (setActiveMediaDevice), а не пересозданием roomOptions. // (setActiveMediaDevice), а не пересозданием roomOptions.
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined }, audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
}), }),
[userChoices], [userChoices, joinState?.publishQualityCap],
) )
if (error) { if (error) {
@@ -287,6 +309,7 @@ export function RoomPage() {
onPinFocus={handlePinFocus} onPinFocus={handlePinFocus}
raisedHandIdentities={raisedHandIdentities} raisedHandIdentities={raisedHandIdentities}
conferenceId={joinState.conferenceId} conferenceId={joinState.conferenceId}
stageMaxTiles={joinState.stageMaxTiles}
/> />
)} )}
{chatVisible && chatOpen && ( {chatVisible && chatOpen && (