Compare commits
4 Commits
286c01d93b
...
v0.0.21
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c997380f | |||
| 173d384f06 | |||
| 7b2427535a | |||
| 8e45038251 |
@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.19
|
||||
VIDCONF_VERSION=0.0.21
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -3,6 +3,38 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](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
|
||||
|
||||
Сеть 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 (комната конференции).
|
||||
|
||||
@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
||||
registration_email_domains=cfg.registration_email_domains,
|
||||
contact_email_enabled=cfg.contact_email_enabled,
|
||||
contact_email=cfg.contact_email,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
|
||||
"""Режим рассылки саммари по умолчанию: всем участникам либо только
|
||||
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
|
||||
|
||||
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
|
||||
"""Потолок качества исходящего видео участника (задача «рычаги качества
|
||||
медиа»). `off` — без ограничения (дефолт, поведение как до появления
|
||||
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
|
||||
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
|
||||
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
|
||||
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
|
||||
|
||||
StageMaxTiles = Literal[4, 9, 16, 25]
|
||||
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
|
||||
режет набор доступных раскладок сетки, что бросает лишних участников на
|
||||
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
|
||||
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
|
||||
|
||||
|
||||
class MediaLimitsConfig(BaseModel):
|
||||
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
|
||||
конференции — статичные потолки, применяются на клиенте при входе)."""
|
||||
|
||||
publish_quality_cap: PublishQualityCap = "off"
|
||||
stage_max_tiles: StageMaxTiles = 25
|
||||
|
||||
|
||||
class InstanceConfig(BaseModel):
|
||||
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
|
||||
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
|
||||
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||
contact_email_enabled: bool = False
|
||||
contact_email: str | None = None
|
||||
# Потолок качества публикации + максимум плиток сцены — см.
|
||||
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
|
||||
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
|
||||
# не только в админке — настройка должна быть на руках у клиента до
|
||||
# публикации трека.
|
||||
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from core.plugins.config import AiLevel, SummaryRecipientsMode
|
||||
from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||
from schemas.conferences import ConferenceOut
|
||||
from services.ai_levels import AiLevelStatus
|
||||
|
||||
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
|
||||
registration_email_domains: list[str] = Field(default_factory=list)
|
||||
contact_email_enabled: bool
|
||||
contact_email: str | None = None
|
||||
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
|
||||
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
|
||||
|
||||
from core.plugins.config import SummaryRecipientsMode
|
||||
from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
|
||||
from schemas.room_events import ForcedMuteSource
|
||||
from services.recurrence import RecurrenceRule
|
||||
|
||||
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
|
||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||
chat_enabled: bool
|
||||
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
class ConferenceOut(BaseModel):
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import json
|
||||
|
||||
from core.config import get_settings
|
||||
from core.plugins.config import PublishQualityCap, StageMaxTiles
|
||||
from core.security import verify_password
|
||||
from models.conference import Conference
|
||||
from schemas.conferences import JoinOut
|
||||
@@ -52,15 +53,18 @@ def build_join(
|
||||
identity: str,
|
||||
name: str,
|
||||
chat_enabled: bool,
|
||||
publish_quality_cap: PublishQualityCap,
|
||||
stage_max_tiles: StageMaxTiles,
|
||||
avatar_url: str | None = None,
|
||||
is_organizer: bool = False,
|
||||
) -> JoinOut:
|
||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||
|
||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
||||
читается здесь параметром, а не заново из БД, чтобы не плодить
|
||||
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
|
||||
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
|
||||
стороной значения `instance_settings`: читаются здесь параметрами, а не
|
||||
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
|
||||
`avatar_url`/`is_organizer`
|
||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||
получают токен вовсе без метаданных, как и раньше).
|
||||
@@ -87,4 +91,6 @@ def build_join(
|
||||
room_name=conference.slug,
|
||||
conference_id=conference.id,
|
||||
chat_enabled=chat_enabled,
|
||||
publish_quality_cap=publish_quality_cap,
|
||||
stage_max_tiles=stage_max_tiles,
|
||||
)
|
||||
|
||||
@@ -149,12 +149,14 @@ class ConferenceService:
|
||||
|
||||
join = None
|
||||
if is_instant:
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
join = build_join(
|
||||
conference,
|
||||
identity=str(owner_id),
|
||||
name=owner_name,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||
is_organizer=True,
|
||||
)
|
||||
@@ -243,12 +245,14 @@ class ConferenceService:
|
||||
"""Войти в конференцию зарегистрированным пользователем."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
await ensure_joinable(conference, password=password)
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=str(user.id),
|
||||
name=user.name_user,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
||||
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
|
||||
)
|
||||
@@ -265,12 +269,14 @@ class ConferenceService:
|
||||
await self._session.flush()
|
||||
await self._session.commit()
|
||||
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=f"guest:{guest.id}",
|
||||
name=data.display_name,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
)
|
||||
|
||||
async def mute_participant(
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||
`registration_team_choice`, `registration_email_domain`, `contact_email`) —
|
||||
`registration_team_choice`, `registration_email_domain`, `contact_email`,
|
||||
`media_limits`) —
|
||||
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||
@@ -28,7 +29,10 @@ from core.plugins.config import (
|
||||
AiLevel,
|
||||
ChatConfig,
|
||||
InstanceConfig,
|
||||
MediaLimitsConfig,
|
||||
PluginsConfig,
|
||||
PublishQualityCap,
|
||||
StageMaxTiles,
|
||||
SummarizerConfig,
|
||||
SummaryRecipientsMode,
|
||||
TranscriberConfig,
|
||||
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||
_KEY_CONTACT_EMAIL = "contact_email"
|
||||
_KEY_MEDIA_LIMITS = "media_limits"
|
||||
|
||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||
_KEY_CHAT,
|
||||
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
|
||||
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
||||
`update()` значение переписывается в новую форму (см. `update`)."""
|
||||
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
|
||||
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
|
||||
|
||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||||
@@ -103,6 +109,8 @@ class SettingsUpdateIn(BaseModel):
|
||||
registration_email_domains: list[str] | None = None
|
||||
contact_email_enabled: bool | None = None
|
||||
contact_email: str | None = None
|
||||
publish_quality_cap: PublishQualityCap | None = None
|
||||
stage_max_tiles: StageMaxTiles | None = None
|
||||
|
||||
|
||||
class BootstrapOverrides(BaseModel):
|
||||
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
|
||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
|
||||
}
|
||||
if overrides is None:
|
||||
return defaults
|
||||
@@ -340,6 +349,20 @@ class InstanceSettingsService:
|
||||
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
|
||||
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
|
||||
|
||||
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
|
||||
cap = (
|
||||
patch.publish_quality_cap
|
||||
if patch.publish_quality_cap is not None
|
||||
else cfg.media_limits.publish_quality_cap
|
||||
)
|
||||
max_tiles = (
|
||||
patch.stage_max_tiles
|
||||
if patch.stage_max_tiles is not None
|
||||
else cfg.media_limits.stage_max_tiles
|
||||
)
|
||||
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
|
||||
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
|
||||
|
||||
await self._session.commit()
|
||||
return cfg
|
||||
|
||||
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||
"enabled", False
|
||||
),
|
||||
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
|
||||
media_limits=MediaLimitsConfig.model_validate(
|
||||
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_400(
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_get_settings_media_limits_defaults(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Дефолты рычагов нагрузки — без ограничения качества и с текущим
|
||||
максимумом сетки (5×5) — существующие инсталляции после обновления не
|
||||
получают внезапно ухудшенное качество."""
|
||||
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["publish_quality_cap"] == "off"
|
||||
assert body["stage_max_tiles"] == 25
|
||||
|
||||
|
||||
async def test_put_settings_media_limits_partial_update(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"publish_quality_cap": "360p", "stage_max_tiles": 9},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["publish_quality_cap"] == "360p"
|
||||
assert body["stage_max_tiles"] == 9
|
||||
|
||||
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
|
||||
assert reloaded.json()["publish_quality_cap"] == "360p"
|
||||
assert reloaded.json()["stage_max_tiles"] == 9
|
||||
|
||||
|
||||
async def test_put_settings_media_limits_invalid_values_return_422(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса
|
||||
ДО сервисного слоя, ещё на уровне FastAPI/pydantic."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
await db_session.commit()
|
||||
|
||||
bad_cap = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"publish_quality_cap": "4k"},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert bad_cap.status_code == 422
|
||||
|
||||
bad_tiles = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"stage_max_tiles": 100},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert bad_tiles.status_code == 422
|
||||
|
||||
|
||||
# --- Тестовое письмо ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
|
||||
assert body["join"]["conference_id"] == body["id"]
|
||||
assert body["join"]["room_name"] == body["slug"]
|
||||
assert body["join"]["token"]
|
||||
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
|
||||
# инсталляции не должны получить внезапно ухудшенное качество).
|
||||
assert body["join"]["publish_quality_cap"] == "off"
|
||||
assert body["join"]["stage_max_tiles"] == 25
|
||||
|
||||
|
||||
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
|
||||
@@ -841,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
|
||||
assert guests[0].email == "alice-guest@example.com"
|
||||
|
||||
|
||||
async def test_guest_join_reflects_admin_configured_media_limits(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
|
||||
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
|
||||
(см. `services/conference_access.py::build_join`), гость админку не видит."""
|
||||
admin = await _make_user(db_session, role="admin")
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
settings_response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert settings_response.status_code == 200, settings_response.text
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||
json={"display_name": "Guest Bob"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["publish_quality_cap"] == "180p"
|
||||
assert body["stage_max_tiles"] == 4
|
||||
|
||||
|
||||
async def test_guest_join_without_email_is_allowed(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
|
||||
@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
"media_limits",
|
||||
)
|
||||
|
||||
|
||||
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
"media_limits",
|
||||
}
|
||||
cfg = await service.get()
|
||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||
@@ -137,6 +139,11 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
assert cfg.registration_email_domains == []
|
||||
assert cfg.contact_email_enabled is False
|
||||
assert cfg.contact_email is None
|
||||
# Дефолты сохраняют текущее (до появления настройки) поведение —
|
||||
# без ограничения качества и с максимумом сетки, равным фактическому
|
||||
# потолку `StageGrid` (5×5).
|
||||
assert cfg.media_limits.publish_quality_cap == "off"
|
||||
assert cfg.media_limits.stage_max_tiles == 25
|
||||
|
||||
|
||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
|
||||
assert cfg.summarizer.enabled is False
|
||||
|
||||
|
||||
async def test_media_limits_partial_update_keeps_untouched_field(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
|
||||
живут в одной строке JSON, `update()` обязан подставлять текущее значение
|
||||
несменённого поля, а не дефолт модели."""
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
|
||||
assert cfg.media_limits.publish_quality_cap == "360p"
|
||||
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
|
||||
assert cfg.media_limits.stage_max_tiles == 9
|
||||
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
|
||||
|
||||
reloaded = await service.get()
|
||||
assert reloaded.media_limits.publish_quality_cap == "360p"
|
||||
assert reloaded.media_limits.stage_max_tiles == 9
|
||||
|
||||
|
||||
async def test_update_rejects_unavailable_ai_level(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
|
||||
@@ -89,7 +89,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.19}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.21}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
@@ -407,16 +407,19 @@ services:
|
||||
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
|
||||
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
|
||||
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
|
||||
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются
|
||||
# нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
|
||||
# публичными.
|
||||
ports:
|
||||
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
|
||||
- "7881:7881" # RTC TCP fallback
|
||||
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
|
||||
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
|
||||
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
|
||||
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
|
||||
- "54000-54100:54000-54100/udp" # WebRTC media (ICE)
|
||||
# Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
|
||||
# мультиплексирует все ICE-сессии через него (rtc.udp_port в
|
||||
# livekit.yaml.template), а не открывает по порту на участника.
|
||||
# На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
|
||||
# 101 порт держали 101 лишний userland-процесс на медиапути.
|
||||
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
|
||||
# macOS заняты Steam/системными процессами и эфемерными QUIC.
|
||||
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -15,16 +15,20 @@ port: 7880
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
|
||||
# расширить и синхронизировать с пробросом портов.
|
||||
port_range_start: 54000
|
||||
port_range_end: 54100
|
||||
# Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
|
||||
# Раньше здесь был port_range_start/port_range_end (54000-54100) — под
|
||||
# каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
|
||||
# (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
|
||||
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
|
||||
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
|
||||
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
|
||||
udp_port: 54000
|
||||
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
|
||||
# разработки (Docker Desktop): use_external_ip=true определяет публичный
|
||||
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
|
||||
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
|
||||
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
|
||||
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент
|
||||
# 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
|
||||
# запускается на том же хосте.
|
||||
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
|
||||
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
|
||||
|
||||
@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
|
||||
"registration_team_choice": false,
|
||||
"registration_email_domain_enabled": false,
|
||||
"registration_email_domain": null,
|
||||
"transcription_queue_served": true
|
||||
"transcription_queue_served": true,
|
||||
"publish_quality_cap": "off",
|
||||
"stage_max_tiles": 25
|
||||
}
|
||||
```
|
||||
|
||||
@@ -408,6 +410,8 @@ Email сразу считается подтверждённым (письмо
|
||||
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
|
||||
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
|
||||
- `transcription_queue_served` — `true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
|
||||
- `publish_quality_cap` — потолок качества исходящего видео публикующего: `off` (без ограничения, дефолт), `720p`, `360p` или `180p`; отдаётся участнику ещё и в join-ответе (`JoinOut`, `POST /api/v1/conferences/{id}/join`/`guest-join`) — до входа в LiveKit-комнату
|
||||
- `stage_max_tiles` — максимум одновременно видимых плиток сетки конференции: `4`, `9`, `16` или `25` (дефолт, совпадает с текущим потолком сетки 5×5, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
|
||||
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
|
||||
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml
|
||||
ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
|
||||
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
|
||||
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
|
||||
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
|
||||
@@ -276,7 +276,7 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
|
||||
# 1. Все сервисы healthy
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
||||
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100,
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
||||
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
|
||||
ss -ltnp
|
||||
|
||||
@@ -318,7 +318,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
|
||||
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
|
||||
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
|
||||
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1,
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
|
||||
firewall) хватает для подавляющего большинства сетей. Включайте этот
|
||||
раздел только если у вас есть конкретные пользователи за CGNAT или
|
||||
жёстким корпоративным firewall, которые не могут установить медиа-соединение
|
||||
|
||||
@@ -179,12 +179,15 @@ dev-стека.
|
||||
профилей битрейта заводить не требуется; при необходимости ограничить
|
||||
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
|
||||
(клиентский SDK, вне скоупа devops-части).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной
|
||||
ступени (максимум 60 участников в тесте) — при планировании прод-узла с
|
||||
ожидаемым бОльшим числом одновременных участников across все комнаты
|
||||
узла держать `port_range_end - port_range_start` заметно больше пикового
|
||||
числа участников на узле (LiveKit резервирует пару портов на участника
|
||||
на медиа-транспорт).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
|
||||
не был узким местом ни на одной ступени (максимум 60 участников). Тогда
|
||||
же с ним была цена: под каждый порт диапазона Docker держал отдельный
|
||||
процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
|
||||
шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
|
||||
мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
|
||||
диапазон шире пикового числа участников» больше не актуальна — портов
|
||||
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
|
||||
поверх одного сокета сам.
|
||||
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
|
||||
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
|
||||
проде, а не только для теста — иначе клиенты в вырожденном случае
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* конверте пагинации `items`/`total`.
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
|
||||
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
||||
export type AiLevel = 'min' | 'medium' | 'max'
|
||||
@@ -39,6 +39,10 @@ export interface SettingsOut {
|
||||
contact_email_enabled: boolean
|
||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||
contact_email: string | null
|
||||
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
|
||||
stage_max_tiles: number
|
||||
}
|
||||
|
||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||
@@ -56,6 +60,8 @@ export interface SettingsUpdateIn {
|
||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||
contact_email_enabled?: boolean
|
||||
contact_email?: string | null
|
||||
publish_quality_cap?: PublishQualityCap
|
||||
stage_max_tiles?: number
|
||||
}
|
||||
|
||||
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||
|
||||
@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
||||
*/
|
||||
export type SummaryRecipientsMode = 'all' | 'owner'
|
||||
|
||||
/**
|
||||
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
|
||||
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
|
||||
* ограничения. Применяется на клиенте через `publishDefaults`
|
||||
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
|
||||
* жёсткое разрешение захвата камеры.
|
||||
*/
|
||||
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
|
||||
|
||||
/**
|
||||
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
|
||||
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
||||
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
|
||||
conference_id: string
|
||||
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
||||
chat_enabled: boolean
|
||||
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
|
||||
stage_max_tiles: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SettingsUpdateIn,
|
||||
type TestEmailOut,
|
||||
} from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ value: 'owner', label: 'Только организатору' },
|
||||
]
|
||||
|
||||
const PUBLISH_QUALITY_CAP_OPTIONS = [
|
||||
{ value: 'off', label: 'Без ограничения' },
|
||||
{ value: '720p', label: 'Не выше 720p' },
|
||||
{ value: '360p', label: 'Не выше 360p' },
|
||||
{ value: '180p', label: 'Не выше 180p' },
|
||||
]
|
||||
|
||||
const STAGE_MAX_TILES_OPTIONS = [
|
||||
{ value: '25', label: '25 (5×5, без ограничения)' },
|
||||
{ value: '16', label: '16 (4×4)' },
|
||||
{ value: '9', label: '9 (3×3)' },
|
||||
{ value: '4', label: '4 (2×2)' },
|
||||
]
|
||||
|
||||
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
|
||||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||||
medium: 'Средний',
|
||||
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const [newDomainInput, setNewDomainInput] = useState('')
|
||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
|
||||
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
|
||||
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
|
||||
const [testEmailTo, setTestEmailTo] = useState('')
|
||||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||
|
||||
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||
payload.contact_email = trimmedContactEmail
|
||||
}
|
||||
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
|
||||
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Нагрузка</h2>
|
||||
<p className="desc">
|
||||
Рычаги для инстансов на слабом канале/железе — режут исходящий трафик и нагрузку на
|
||||
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
|
||||
</p>
|
||||
|
||||
<div className="settings-card-body settings-card-body--spread">
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
|
||||
Потолок качества публикации видео
|
||||
</label>
|
||||
<Select
|
||||
id="settings-quality-cap"
|
||||
aria-labelledby="settings-quality-cap-label"
|
||||
value={publishQualityCap}
|
||||
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
|
||||
options={PUBLISH_QUALITY_CAP_OPTIONS}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Ограничивает битрейт исходящего видео публикующего — снижает нагрузку на его канал и
|
||||
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
|
||||
стороны отдельно)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
|
||||
Максимум плиток на экране
|
||||
</label>
|
||||
<Select
|
||||
id="settings-max-tiles"
|
||||
aria-labelledby="settings-max-tiles-label"
|
||||
value={String(stageMaxTiles)}
|
||||
onChange={(v) => setStageMaxTiles(Number(v))}
|
||||
options={STAGE_MAX_TILES_OPTIONS}
|
||||
/>
|
||||
<p className="field-hint">
|
||||
Сверх лимита участники уходят на следующую страницу сетки вместо подписки — меньше
|
||||
одновременных видеопотоков на канал и экран участника
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-card">
|
||||
<h2>Контактный адрес</h2>
|
||||
<p className="desc">
|
||||
|
||||
@@ -206,6 +206,7 @@ export function RoomStage({
|
||||
onPinFocus,
|
||||
raisedHandIdentities,
|
||||
conferenceId,
|
||||
stageMaxTiles,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
@@ -232,6 +233,8 @@ export function RoomStage({
|
||||
raisedHandIdentities?: Set<string>
|
||||
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
|
||||
conferenceId?: string
|
||||
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
|
||||
stageMaxTiles?: number
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
@@ -429,14 +432,14 @@ export function RoomStage({
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
@@ -445,7 +448,7 @@ export function RoomStage({
|
||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||
if (sideTracks.length === 0 && !hideOthers) {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
TrackLoop,
|
||||
@@ -47,6 +47,17 @@ interface StageGridProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||
children: ReactNode
|
||||
/**
|
||||
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
|
||||
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
|
||||
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
|
||||
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
|
||||
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
|
||||
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
|
||||
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
|
||||
* то есть перестают быть подписанными треками, а не просто визуально мельче.
|
||||
*/
|
||||
maxTiles?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +75,7 @@ interface StageGridProps {
|
||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||
*/
|
||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
|
||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||
const gridLayouts = useMemo(
|
||||
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
|
||||
[maxTiles],
|
||||
)
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridRef, {
|
||||
|
||||
36
frontend/src/lib/publishQualityCap.ts
Normal file
36
frontend/src/lib/publishQualityCap.ts
Normal 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]
|
||||
}
|
||||
@@ -146,6 +146,8 @@ export function JoinPage() {
|
||||
title: resolved.title,
|
||||
conferenceId: data.conference_id,
|
||||
chatEnabled: data.chat_enabled,
|
||||
publishQualityCap: data.publish_quality_cap,
|
||||
stageMaxTiles: data.stage_max_tiles,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -43,6 +43,8 @@ export function LobbyPage() {
|
||||
title: conference.title,
|
||||
conferenceId: conference.join.conference_id,
|
||||
chatEnabled: conference.join.chat_enabled,
|
||||
publishQualityCap: conference.join.publish_quality_cap,
|
||||
stageMaxTiles: conference.join.stage_max_tiles,
|
||||
number: conference.number,
|
||||
slug: conference.slug,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react
|
||||
import type { RoomOptions } from 'livekit-client'
|
||||
import '@livekit/components-styles'
|
||||
import '@/styles/room.css'
|
||||
import { joinConference, resolveConference } from '@/api/conferences'
|
||||
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { useChat } from '@/hooks/useChat'
|
||||
@@ -19,6 +19,7 @@ import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
import { buildPublishDefaults } from '@/lib/publishQualityCap'
|
||||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
interface RoomJoinState {
|
||||
@@ -31,6 +32,18 @@ interface RoomJoinState {
|
||||
number?: string
|
||||
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
||||
chatEnabled?: boolean
|
||||
/**
|
||||
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
|
||||
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
|
||||
* первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
|
||||
* стабильность его ссылки): `joinState` целиком появляется одним актом
|
||||
* (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
|
||||
* (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
|
||||
* на руках к моменту публикации трека, без отдельного асинхронного
|
||||
* похода за настройками после подключения и без риска переподключения.
|
||||
*/
|
||||
publishQualityCap?: PublishQualityCap
|
||||
stageMaxTiles?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,6 +110,8 @@ export function RoomPage() {
|
||||
title: info.title,
|
||||
conferenceId: result.conference_id,
|
||||
chatEnabled: result.chat_enabled,
|
||||
publishQualityCap: result.publish_quality_cap,
|
||||
stageMaxTiles: result.stage_max_tiles,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -202,9 +217,16 @@ export function RoomPage() {
|
||||
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
||||
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
||||
// не делаем (сохранение — только в DeviceSettingsDialog).
|
||||
//
|
||||
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
|
||||
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
|
||||
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
|
||||
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
|
||||
// подключённого участника.
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
const roomOptions = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
|
||||
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
|
||||
// подписан на полное качество всех чужих треков независимо от размера
|
||||
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
|
||||
@@ -230,7 +252,7 @@ export function RoomPage() {
|
||||
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||
}),
|
||||
[userChoices],
|
||||
[userChoices, joinState?.publishQualityCap],
|
||||
)
|
||||
|
||||
if (error) {
|
||||
@@ -287,6 +309,7 @@ export function RoomPage() {
|
||||
onPinFocus={handlePinFocus}
|
||||
raisedHandIdentities={raisedHandIdentities}
|
||||
conferenceId={joinState.conferenceId}
|
||||
stageMaxTiles={joinState.stageMaxTiles}
|
||||
/>
|
||||
)}
|
||||
{chatVisible && chatOpen && (
|
||||
|
||||
Reference in New Issue
Block a user