feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
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:
@@ -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