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 отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
This commit is contained in:
2026-08-02 19:53:23 +03:00
parent 7b2427535a
commit 173d384f06
20 changed files with 389 additions and 23 deletions

View File

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