diff --git a/backend/api/admin.py b/backend/api/admin.py index 67ec176..879063b 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -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, ) diff --git a/backend/core/plugins/config.py b/backend/core/plugins/config.py index efcfc13..46cc8f1 100644 --- a/backend/core/plugins/config.py +++ b/backend/core/plugins/config.py @@ -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) diff --git a/backend/schemas/admin.py b/backend/schemas/admin.py index 69d16c3..4730cf0 100644 --- a/backend/schemas/admin.py +++ b/backend/schemas/admin.py @@ -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): diff --git a/backend/schemas/conferences.py b/backend/schemas/conferences.py index 5ece9da..06bcb1e 100644 --- a/backend/schemas/conferences.py +++ b/backend/schemas/conferences.py @@ -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): diff --git a/backend/services/conference_access.py b/backend/services/conference_access.py index cafe546..bf075dc 100644 --- a/backend/services/conference_access.py +++ b/backend/services/conference_access.py @@ -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, ) diff --git a/backend/services/conferences.py b/backend/services/conferences.py index e437277..1590065 100644 --- a/backend/services/conferences.py +++ b/backend/services/conferences.py @@ -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( diff --git a/backend/services/instance_settings.py b/backend/services/instance_settings.py index f1c3cef..1f714df 100644 --- a/backend/services/instance_settings.py +++ b/backend/services/instance_settings.py @@ -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) + ), ) diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index a7ae843..e45b61f 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -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 + + # --- Тестовое письмо ---------------------------------------------------------------- diff --git a/backend/tests/test_conferences_api.py b/backend/tests/test_conferences_api.py index 2cc1a1b..801362c 100644 --- a/backend/tests/test_conferences_api.py +++ b/backend/tests/test_conferences_api.py @@ -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: diff --git a/backend/tests/test_instance_settings.py b/backend/tests/test_instance_settings.py index 61dd2c8..c297a24 100644 --- a/backend/tests/test_instance_settings.py +++ b/backend/tests/test_instance_settings.py @@ -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: diff --git a/docs/api/admin.md b/docs/api/admin.md index 8c35682..fee4242 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации --- diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 3e5d062..46e7827 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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`). */ diff --git a/frontend/src/api/conferences.ts b/frontend/src/api/conferences.ts index de7d0d0..5e63f2b 100644 --- a/frontend/src/api/conferences.ts +++ b/frontend/src/api/conferences.ts @@ -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 } /** diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index dddd45a..e9f0ffc 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -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 = { 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(data.publish_quality_cap) + const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles) const [testEmailTo, setTestEmailTo] = useState('') const [testEmailResult, setTestEmailResult] = useState(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 }) { +
+

Нагрузка

+

+ Рычаги для инстансов на слабом канале/железе — режут исходящий трафик и нагрузку на + устройство участника. Дефолты сохраняют прежнее поведение (без ограничений). +

+ +
+
+ + setStageMaxTiles(Number(v))} + options={STAGE_MAX_TILES_OPTIONS} + /> +

+ Сверх лимита участники уходят на следующую страницу сетки вместо подписки — меньше + одновременных видеопотоков на канал и экран участника +

+
+
+
+

Контактный адрес

diff --git a/frontend/src/components/room/RoomStage.tsx b/frontend/src/components/room/RoomStage.tsx index 28f89b0..ed439b2 100644 --- a/frontend/src/components/room/RoomStage.tsx +++ b/frontend/src/components/room/RoomStage.tsx @@ -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 /** 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 ( - + ) } if (effectiveMode === 'live-tiles') { return ( - 0 ? liveCameraTracks : cameraTracks}> + 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}> ) @@ -445,7 +448,7 @@ export function RoomStage({ // прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка. if (sideTracks.length === 0 && !hideOthers) { return ( - + ) diff --git a/frontend/src/components/room/StageGrid.tsx b/frontend/src/components/room/StageGrid.tsx index 4575d20..4e5e310 100644 --- a/frontend/src/components/room/StageGrid.tsx +++ b/frontend/src/components/room/StageGrid.tsx @@ -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(null) // Хуки библиотеки объявлены с `RefObject` (типы React 18, где // `current` был readonly и тип вёл себя ковариантно). В типах React 19 @@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) { // параметр уже не присваивается — приведение безопасно: оба хука только // читают `.current` (ResizeObserver и слушатели touch-событий). const gridRef = gridEl as RefObject - 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, { diff --git a/frontend/src/lib/publishQualityCap.ts b/frontend/src/lib/publishQualityCap.ts new file mode 100644 index 0000000..8e766f1 --- /dev/null +++ b/frontend/src/lib/publishQualityCap.ts @@ -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, 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] +} diff --git a/frontend/src/pages/JoinPage.tsx b/frontend/src/pages/JoinPage.tsx index ff40594..d0f0b74 100644 --- a/frontend/src/pages/JoinPage.tsx +++ b/frontend/src/pages/JoinPage.tsx @@ -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) { diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 4138671..ee50866 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -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, }, diff --git a/frontend/src/pages/RoomPage.tsx b/frontend/src/pages/RoomPage.tsx index 7a5e5be..ae002d8 100644 --- a/frontend/src/pages/RoomPage.tsx +++ b/frontend/src/pages/RoomPage.tsx @@ -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( () => ({ + 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 && (