Compare commits

...

46 Commits

Author SHA1 Message Date
41aca319fc release: версия 0.0.37
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
2026-08-10 16:10:01 +03:00
1ab146e4b0 fix(room): нормализовать сентинел "default" выбора устройства
usePersistentUserChoices хранит «устройство не выбрано» как литерал
"default", а не пустую строку — deviceId: userChoices.audioDeviceId ||
undefined на него не срабатывал, и в deviceId уходил сентинел. В Chrome
это давало OverconstrainedError на первом визите с лишним неудачным
проходом до фолбэка. Та же нормализация уже была сделана в 0.0.33 для
useDeviceCheckAccess.ts — здесь то же самое для комнаты.
2026-08-10 16:09:44 +03:00
9abe5b5102 fix(deploy): починить DATABASE_URL для локального контейнерного стенда
Корневой .env указывает DATABASE_URL на localhost (нужен только для
запуска backend на хосте вне контейнера), а compose подставлял эту
строку внутрь контейнеров backend/worker/worker-transcriber как есть —
localhost внутри контейнера это он сам, ConnectionRefused при любом
docker compose up без обходной переменной окружения (грабля с 04.08).

DATABASE_URL для контейнеров теперь всегда собирается в compose из
POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB с хостом postgres — так же,
как уже сделано для REDIS_URL. Прода не касается: install.sh (строка 380)
и так пересобирает DATABASE_URL при установке с хостом postgres.

Проверено на локальном стенде: docker compose --profile media up -d без
обходной переменной, backend/worker подключились к БД без ConnectionRefused.
2026-08-10 16:09:38 +03:00
6db142d45a docs(env): описать живые ключи .env.example, убрать мёртвые
MEDIA_URL и VITE_API_URL нигде не читаются (grep по backend/, frontend/src/,
deploy/ не даёт совпадений) — удалены. MEDIA_ROOT и EGRESS_START_TIMEOUT_S
живут в backend/core/config.py, но отсутствовали в .env.example — добавлены
с описанием. LIVEKIT_WS_URL читается в deploy/docker-compose.yml (egress) и
deploy/egress/egress.yaml, в Python-конфиге его нет — тоже описан.
2026-08-10 16:09:27 +03:00
1c2a9cd24a style(backend): разобрать 13 предупреждений ruff (E501)
Реальный код переформатирован (models/user.py, services/instance_settings.py,
tests/test_auth.py). Текст регламента обработки персональных данных вынесен
в services/consent_policy_text.py с точечным per-file-ignore E501 (как и
email_templates.py) — это прозаический шаблон документа, а не код, оборачивать
его строки ради лимита длины бессмысленно.
2026-08-10 16:09:19 +03:00
c4485d43b1 fix(metrics): развязать vidconf_pipeline_sessions с основным пулом БД
_refresh_pipeline_sessions_gauge и metrics_endpoint ходили через
Depends(get_session) — основной пул, разделяемый с API-запросами. В
инциденте 07.08 это дало 16 падений в api/metrics.py ровно тогда, когда
метрики были нужнее всего (пул исчерпан). db_up/db_pool_* уже были
развязаны в 0.0.32, эта метрика — нет (мешал тестовый харнесс).

Добавлен get_metrics_session (core/db.py) — отдельный движок с NullPool,
как у check_db_up, но с полноценной ORM-сессией для репозитория. Тестовый
харнесс (app-фикстура) подменяет её на ту же savepoint-сессию, что и
get_session, — иначе /metrics не видел бы данные теста.
2026-08-10 16:09:09 +03:00
0a13589612 fix(tests): изолировать тесты от instance_settings в общей dev-БД
Тесты читали ту же instance_settings, что и dev-стенд: выключение
chat/hand_queue в админке роняло пачку тестов, не связанных с самим
переключением (наступила сессия 0.0.28). Фикстура clean_instance_settings
(явная, не autouse — DELETE в savepoint держит блокировку строки до
конца внешней транзакции теста, автовключение на тестах с отдельными
подключениями к БД дало саморазблокировку) удаляет управляемые ключи
перед тестом, чтение конфигурации падает на дефолты pydantic-моделей.
Подключена в test_chat_ws.py и test_hand_queue_ws.py.

MANAGED_KEYS в services/instance_settings.py — единый список управляемых
ключей вместо локальной копии в тестовом файле.
2026-08-10 16:08:56 +03:00
6554fa242b release: версия 0.0.36 2026-08-10 13:16:16 +03:00
1089d874af fix(room): замена фона в Safari и Firefox
Проверка поддержки была строже, чем требует библиотека, и молча отрезала два
браузера: кнопки выбора фона не было ни в превью, ни в комнате.

У `@livekit/track-processors` ДВА конвейера обработки кадров: современный
(`MediaStreamTrackProcessor`/`Generator`, только Chrome и производные) и
запасной — рисует кадры в canvas и отдаёт `canvas.captureStream()`. Проверка
требовала именно современный, хотя запасной путь доступен и в Safari, и в
Firefox. Заодно она проверяла `OffscreenCanvas`, но пропускала `VideoFrame`,
`createImageBitmap` и WebGL2, которые библиотеке реально нужны.

Теперь условие буквально повторяет `supportsBackgroundProcessors()` из самой
библиотеки: «умеет считать сегментацию» И «есть хоть какой-то конвейер».
При обновлении пакета сверять с ним.

Пробный WebGL2-контекст (иначе поддержку не определить) создаётся один раз на
жизнь страницы и сразу отпускается через `WEBGL_lose_context`: браузеры
держат ограниченное число живых контекстов.

Проверено: в Firefox 153 детект возвращает «поддерживается» (современного
конвейера нет, запасной есть), в Chrome регрессии нет — процессор
поднимается, трек живой.
2026-08-10 13:16:01 +03:00
bed041db75 release: версия 0.0.35
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-10 09:00:46 +03:00
b0e30ead57 chore(nginx): раздача ассетов сегментации со своего домена
Отдельный `location /mediapipe/` под рантайм MediaPipe: gzip (без него wasm
едет все 9 МБ, со сжатием — около трёх) и долгий кэш — файлы неизменны в
пределах сборки образа и скачиваются один раз на браузер. Модель (.tflite)
из списка gzip исключена намеренно: внутри она уже сжатый архив.
2026-08-10 08:59:58 +03:00
17437880b1 feat(room): замена фона видео на картинку — только на десктопе
Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`LocalVideoTrack.setProcessor`), а НЕ пересозданием
`RoomOptions` — ссылка на них обязана оставаться стабильной, иначе
`LiveKitRoom` переподключается к комнате.

Фича только для ДЕСКТОПА, и «десктоп» определяется по возможностям устройства
(`pointer: fine` + `hover: hover` + `maxTouchPoints`), а НЕ по ширине окна:
узкое окно на десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет,
который сегментация греет. На мобильном кнопки нет вовсе, а не задизейбленной.

Ассеты сегментации отдаются СО СВОЕГО домена: библиотека по умолчанию тянет
wasm с jsdelivr, а модель с storage.googleapis.com, и в закрытом контуре фича
молча не работала бы. Модель (Apache 2.0, см. NOTICE.txt) лежит в репозитории,
wasm-рантайм (~19 МБ) копируется из node_modules плагином сборки. Сама
библиотека и модель грузятся ЛЕНИВО — только когда фон реально включают, вход
в конференцию не стал медленнее.

Три дефолтные сцены — собственные векторные рисунки (`design/backgrounds/`),
а не фотографии из интернета: у нарисованной сцены нет чужой лицензии, а
продукт расходится по инсталляциям, и проверять права на каждую копию некому.

Свои картинки — в профиле, до 10 штук, с уменьшением до 1280px и переводом в
WebP прямо в браузере перед отправкой. Удаление применённого сейчас фона
сбрасывает выбор на «без фона»: хранится ключ записи, а не URL картинки.

Смена камеры фон не теряет (`restartTrack` перезапускает процессор сам),
выключение и включение камеры — навешивает его на новый трек заново.

⚠️ Прокси dev-сервера для `/media/` — обязательно со слэшем: ключ `/media`
Vite матчит префиксом и перехватывает заодно `/mediapipe/...`, из-за чего
модель получала 404 и фон молча не включался.
2026-08-10 08:59:47 +03:00
fec9255baa feat(backend): модуль «замена фона» и хранилище своих картинок
Отключаемый в админке модуль `virtual_background` (дефолт — выключен, чтобы
обновление не меняло продукт у тех, кто ничего не просил). Флаг едет клиенту
двумя путями: на публичные страницы входа — через `GET /public/settings`,
участнику комнаты — в join-ответе (`JoinOut`), потому что значение нужно на
руках ДО первого рендера комнаты, а `/admin/settings` доступен только админу.

Свои картинки пользователя (`/users/me/backgrounds`, GET/POST/DELETE):
файлы на диске (`backgrounds/{user_id}/{id}.{ext}`), в БД только путь — как у
аватаров, «чтобы не грузили БД». Лимит в 10 штук проверяется на сервере под
блокировкой строки пользователя: две одновременные загрузки иначе обе увидели
бы «уже девять» и обе прошли бы. Удаление сносит и запись, и файл; чужую
картинку по её id удалить нельзя — владелец в условии запроса.

Валидация загрузки (допустимые форматы, магические байты, реальный размер)
выделена из `services/avatars.py` в общий `services/images.py`: правила у
аватара и фона одни и те же, а разъехавшись, они дали бы дыру ровно там, ради
чего проверка и написана. Публичный API аватаров не изменился.

Сжимает картинку клиент (Pillow на бэкенде нет), но серверная валидация
остаётся полноценной — запрос может прийти и мимо интерфейса.

Новый ключ настройки вписан в `_MANAGED_KEYS` тестов: без этого включённый
в общей dev-БД модуль ронял чужие тесты, которые считают себя изолированными.
2026-08-10 08:59:16 +03:00
88401d6aa1 release: версия 0.0.34
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 21:24:22 +03:00
ca0b1e23fa fix(auth): превью и проверка устройств на шаге "Подключиться к конференции"
Продолжение 33: раньше превью показывалось только на карточке "Как вас
зовут?" (guest-info), а авторизованный пользователь, входящий через
/join, этот шаг вообще не проходит (сразу connecting) — значит, никогда
не видел проверку устройств и не мог задать enterWithVideo/Audio.

Теперь превью и кнопки — на обоих шагах (input и guest-info), с одним
непрерывным потоком: hook enabled/release эффект завязаны на общий флаг
"мы на одном из шагов с превью", а не на конкретный step, иначе переход
input -> guest-info выглядел бы для эффекта как уход с гашением камеры.

Заодно нашёл и починил реальную грабли: <video> на разных шагах — это
разные DOM-узлы (разные позиции в JSX), обычный ref.current не пережил
бы переезд между ними — поток остаётся жив, но картинка гаснет в чёрный
прямоугольник. videoRef хука теперь callback-ref, переподключающий уже
открытый поток к любому новому узлу автоматически.
2026-08-09 21:22:34 +03:00
9463e64e73 release: версия 0.0.33
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 18:11:04 +03:00
7ee68b17ee feat(auth): проверка устройств на входе — запрос доступа и превью камеры
Отключаемый модуль (instance_settings.device_check, дефолт выключен):
запрос доступа к камере/микрофону на LoginPage и в карточке "Как вас
зовут?" (JoinPage), живое зеркальное превью и кнопки вкл/выкл камеры и
микрофона там же. На JoinPage кнопки определяют, с чем гость войдёт в
конференцию (RoomPage.LiveKitRoom audio/video вместо жёстких false) —
на LoginPage только пре-авторизуют разрешение, без UI (карточка ведёт
в лобби, применить выбор некуда). Вход в комнату по умолчанию, как и
раньше, с выключенными микрофоном/камерой.

Публичный GET /api/v1/public/settings отдаёт флаг модуля обеим
страницам до аутентификации.
2026-08-09 18:09:10 +03:00
ba01548088 release: версия 0.0.32
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 02:40:10 +03:00
3a290c7fc2 feat(monitoring): алерты на недоступность БД и исчерпание пулов + дашборд
DatabaseUnavailable (vidconf_db_up == 0, for: 30s, critical) и
DbConnectionPoolNearExhaustion/RedisConnectionPoolNearExhaustion (занято
> 80% дольше минуты, warning) — сигнал оператору, не автолечение:
healthcheck backend'а по решению оператора остаётся мягким, рестарт при
недоступной БД оборвал бы WS у всех, кто в конференциях.

Дашборд Grafana «БД и пулы соединений» — занятость пулов на графике,
следующий нагрузочный тест будут смотреть глазами.
2026-08-09 02:40:06 +03:00
0e56960714 feat(metrics): метрики доступности БД и занятости пулов БД/Redis
vidconf_db_up проверяется отдельным от основного пула соединением
(NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка
сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят».
vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса
к БД. metrics_endpoint больше не виснет и не падает при недоступном
основном пуле: критичные gauge'и считаются первыми и не зависят от него,
а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) —
тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом
и try/except.
2026-08-09 02:39:59 +03:00
c906c97cb8 release: версия 0.0.31
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 01:07:41 +03:00
296ce60c78 fix(auth): не разлогинивать пользователя, когда серверу плохо
Silent-refresh считал неудачей любой не-2xx ответ и на каждую такую
неудачу сбрасывал access-токен с редиректом на /login. Ответ 500 — это
«серверу плохо», а не «вы не авторизованы»: 07.08.2026 refresh отвечал
500 из-за исчерпанного пула БД, и фронтенд разлогинивал людей посреди
работы, а повторный вход падал тем же 500.

`refreshAccessToken` теперь различает причины: `invalid` (backend отверг
сессию — 4xx, единственный случай для разлогина), `unavailable` (5xx,
таймаут, обрыв сети — сессия цела, токен сохраняется, пользователь
получает обычную ошибку запроса) и `ok`. Восстановление сессии при
старте приложения на `unavailable` повторяет попытку трижды с задержками
1/2/4 с, вместо того чтобы сразу объявить пользователя неавторизованным.
2026-08-09 01:06:15 +03:00
451c18e42b fix(redis): задать размер пула соединений явно
redis-py 8 поставил дефолт `max_connections=100`, а у нас на этом пуле
висят не только команды, но и долгоживущие pub/sub-подписки комнаты — по
одной на каждого участника, пока он в конференции. Сотый участник на
воркер выгребал пул, и WS-хендшейк падал уже на `hgetall` очереди рук с
`MaxConnectionsError`.

Второй потолок того же рода, что и пул БД, только этажом ниже.
Воспроизведён локально: при 99 одновременных WS вход переставал
работать; с `redis_max_connections=500` те же 120 подключений проходят
без единой ошибки. Соединения создаются по мере надобности, поэтому сам
по себе поднятый лимит ничего не стоит.
2026-08-09 01:06:15 +03:00
f7c4fb4176 fix(chat): не держать соединение с БД всю жизнь WS-подключения
Обработчик `WS /conferences/{id}/chat` получает `AsyncSession` через
`Depends(get_session)`, а хендшейк делает четыре SELECT'а (тоггл чата,
конференция, тоггл рук, история). SQLAlchemy открывает транзакцию на
первом из них и держит её — вместе с соединением из пула — всё время,
пока участник сидит в комнате. Соединений в пуле `db_pool_size +
db_max_overflow` = 20 на воркер, то есть 40 на инстанс из двух воркеров:
сороковой вошедший выгребал пул досуха.

Ровно это положило вход на нагрузочном тесте 07.08.2026: 245 ошибок
`QueuePool limit ... timed out`, 170 ответов 500 (из них 123 на резолве
конференции и 21 на гостевом входе), а `pg_stat_activity` показывал рост
`idle in transaction` 3 → 8 → 16 → 26 → 35 → 39 → 40 при одном `active`.
Число открытых WS чата в логах backend растёт синхронно и упирается в
те же 40 ровно к моменту первого таймаута пула.

Соединение освобождается сразу после хендшейка: дальше оба насоса
работают через Redis, а единственная запись в БД (`persist_and_publish`)
открывает и коммитит собственную транзакцию.

Замер на локальном стенде (один воркер, потолок пула 20), 15 посторонних
запросов на каждой ступени:

| участников | idle in transaction | 5xx | p95      |
|------------|---------------------|-----|----------|
| было  20   | 20                  | 10  | 10.05 с  |
| стало 20   | 0                   | 0   | 0.02 с   |
| стало 120  | 0                   | 0   | 0.03 с   |

До правки 21-й участник не мог войти вовсе (500 на guest-join), в логе
40 ошибок `QueuePool limit`; после — ни одной на 120 участниках.
2026-08-09 01:05:59 +03:00
e25b8c28de release: версия 0.0.30
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 22:21:36 +03:00
4f82ebe17a feat(auth): согласие на обработку персональных данных при регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00
0e029a2bf8 release: версия 0.0.29
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 21:13:49 +03:00
f89bf1ad64 feat(room): кнопка демонстрации экрана в мини-окне
В Document PiP (Chrome/Edge) своего тулбара нет, и начать демонстрацию,
не свернув мини-окно, было нельзя. Кнопка сделана по образцу кнопки
микрофона, добавленной в 0.0.15: тот же `useTrackToggle` через
`RoomContext`, поэтому она и кнопка основного тулбара — два вида одного
состояния и рассинхрону взяться неоткуда.

Главный вопрос задачи — пустит ли платформа `getDisplayMedia()`, вызванный
из кода основного окна по клику в ДРУГОМ окне. Замер в Chrome 150: после
клика в PiP `navigator.userActivation.isActive === true` в обоих окнах,
активация доезжает до опенера, вызов проходит, системный пикер выбора
экрана открывается отдельным окном поверх всего, а не прячется за
заглушкой основного окна.

Опции захвата (`SCREEN_SHARE_CAPTURE_OPTIONS`) вынесены из `RoomToolbar` в
`lib/screenShareOptions.ts`: кнопок демонстрации теперь две, и разойдись
они хотя бы в `audio`, демонстрация получалась бы разной в зависимости от
того, откуда её запустили.

Фокус в мини-окне не менялся: своя демонстрация показывается по тем же
правилам `pickStageFocus`, что и в основном окне.

В Safari мини-окно — нативный video-PiP без собственного DOM, кнопке там
негде жить; в Firefox мини-окна нет вовсе. Это ограничение платформы.
2026-08-04 21:13:27 +03:00
aee76329c4 release: версия 0.0.28 2026-08-04 18:29:22 +03:00
10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00
65fbcf952c release: версия 0.0.27
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 02:52:49 +03:00
b44652d6a6 fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию
Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

Разрывы, после которых возвращаться нельзя (выгнал организатор,
конференция закрыта, вход той же личностью с другого устройства),
уводят в лобби как раньше. От бесконечного цикла "вошёл — сразу
выбросило" защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
2026-08-04 02:52:22 +03:00
d11b808e97 release: версия 0.0.26
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 00:01:22 +03:00
ed6f9fff44 feat(room): полноэкранный режим на мобильном с выезжающим тулбаром
Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии
с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме
топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик
по сцене вне элементов управления показывает их, сами прячутся через
несколько секунд бездействия; на десктопе дополнительно — наведение мыши
в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе.

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
2026-08-04 00:00:45 +03:00
06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
a895782250 release: версия 0.0.25
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 18:24:13 +03:00
fa8270c156 fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00
a9e24f6692 fix(room): в Chrome пропадал звук после возврата из мини-окна
RoomAudioRenderer жил внутри RoomStage и рендерился дважды — в ветке
variant="pip" и в основной. При открытии Document PiP сцена
размонтируется в основном окне и монтируется в PiP-окне, поэтому
скрытые <audio> с чужими аудиотреками физически переезжали в ДРУГОЙ
документ, а при возврате — обратно. После такого переезда Chrome
теряет аудиовыход у remote-трека: пакеты продолжают приходить
(packetsReceived растёт), но totalSamplesDuration и totalAudioEnergy
замирают, и трек молчит даже в свежесозданном <audio> со свежим
MediaStream. Тот же цикл detach/attach в пределах одного документа
безвреден — дело именно в переезде между документами. В Safari бага не
было: там Document PiP не используется (video-фолбэк), сцена остаётся
в основном окне.

Рендерер вынесен в RoomPage — один экземпляр, всегда в основном
документе. В PiP-окне аудиоэлементов теперь нет вовсе, цикл «открыл
мини-окно → вернул» их не касается.
2026-08-03 18:19:19 +03:00
8b63c24332 release: версия 0.0.24
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 01:58:32 +03:00
cd5399f88a fix(auth): заголовок вылезал за брендовую панель на широком окне
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Кегль «Ваша инфраструктура» уменьшался только в @media по ширине ОКНА —
не спасало, когда окно широкое, а панель (42% от него, `.layout` flex
42/58) сама по себе узкая: слово не помещалось, overflow:hidden обрезал
его вместо переноса. Перешли на container query (`container-type:
inline-size` на .brand-panel + `cqw` на .brand-headline) — тот же приём,
что у аватара участника в комнате (container-type:size + cqmin,
room.css). Кегль теперь считается от реальной ширины панели, а не окна,
в любой раскладке.

Тем же механизмом клипались плашки .brand-stats («AI-саммари») — добавлен
flex-wrap в базовое правило вместо только-мобильного.
2026-08-03 01:56:42 +03:00
2be799b19d fix(room): эмодзи-поповер в чате — 5-я колонка вылезала за рамку
Настоящая причина (подтверждена вживую через getBoundingClientRect):
`.chat-input-row button` (стиль круглой кнопки «Отправить», width/height
42px) красил размер и кнопкам эмодзи внутри поповера — они тоже лежат в
.chat-input-row. Сетка на 5 колонок по 42px требовала ~238px при
фиксированной ширине попапа 220px, лишнее уезжало вправо за рамку.
Явные width/height: auto на .chat-emoji-option перебивают унаследованный
размер нужной специфичностью, дальше квадрат считает aspect-ratio от
реальной ширины колонки (~36px). Заодно — max-width на попап и
overflow:hidden/white-space:nowrap на кнопки как общая страховка от
переполнения на очень узких экранах.
2026-08-03 01:56:27 +03:00
6b9d0b833e fix(room): чат вылезает за панель в Firefox — min-width/min-height:0
`.chat-messages` (flex-колонка) без явного min-height:0 не сжимается до
flex:1 в Firefox и растёт по контенту списка сообщений. Textarea в
.chat-input-row без min-width:0 упирается в автоматическую минимальную
ширину, которую Firefox считает от атрибута cols (умолчание 20 символов) —
жёстче, чем Chrome/Safari.
2026-08-03 01:56:03 +03:00
f4e8f91839 release: версия 0.0.23
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 22:29:34 +03:00
fb50c5d8ea docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00
826a0a391a fix(coturn): verbose-логирование — иначе ALLOCATE/CreatePermission не видны в docker logs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Дефолтный simple-log в coturn не пишет построчно ALLOCATE/CreatePermission/
Refresh даже при реально работающем relay (найдено на релизе 0.0.22:
рабочий звонок через TURN, но grep -ci allocate по логам coturn = 0,
подтверждение пришлось брать из логов LiveKit). Добавлен флаг verbose
(умеренный режим, не Verbose/-V — тот слишком шумный). DEPLOYMENT.md
предупреждает, что проверка через grep -ci allocate надёжна только с
этим флагом.
2026-08-02 22:11:32 +03:00
112 changed files with 6572 additions and 355 deletions

View File

@@ -2,6 +2,11 @@
POSTGRES_USER=vidconf
POSTGRES_PASSWORD=vidconf
POSTGRES_DB=vidconf
# DATABASE_URL нужен ТОЛЬКО для запуска backend/worker на хосте вне docker-сети
# (см. docs/deploy/dev-setup.md) — внутри контейнеров docker-compose.yml
# всегда собирает postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# сам (сессия 37), это значение игнорируя. Держите пароль в этой строке
# синхронным с POSTGRES_PASSWORD.
DATABASE_URL=postgresql+asyncpg://vidconf:vidconf@localhost:5432/vidconf
# --- Redis ---
@@ -104,6 +109,12 @@ UVICORN_WORKERS=2
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10
# Пул соединений с Redis НА КАЖДЫЙ воркер. Считается по УЧАСТНИКАМ, а не по
# запросам: WS-подключение комнаты держит собственную pub/sub-подписку всё
# время, пока человек в конференции. Дефолт redis-py (100) упирался в потолок
# примерно на сотом одновременном участнике на воркер. Сверху ограничивает
# maxclients самого Redis (по умолчанию 10000) — на все процессы разом.
REDIS_MAX_CONNECTIONS=500
# --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
@@ -122,7 +133,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.22
VIDCONF_VERSION=0.0.37
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
@@ -184,7 +195,22 @@ GRAFANA_ADMIN_PASSWORD=change-me-grafana
# ссылаются на них (проверено grep'ом при харденинге репозитория). Оставлены
# здесь только для полноты покрытия реальных ключей сервера; вычистить или
# начать использовать — по итогам Сессии 2 (bug hunt). ---
MEDIA_URL=http://localhost/media/
VITE_API_URL=http://localhost
VITE_LIVEKIT_URL=ws://localhost:7880
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
# --- Медиа (аватары пользователей, backend/core/config.py: media_root) ---
# Каталог, куда сохраняются загруженные файлы (аватары); раздаётся статикой
# по /media (dev) либо через nginx location /media/ в проде.
MEDIA_ROOT=media
# --- Egress (backend/core/config.py: egress_start_timeout_s) ---
# Таймаут ожидания ответа Track Egress (секунды). Когда egress-сервис в
# деплое не поднят (профиль transcribe отсутствует), не ждать его штатный
# (гораздо более долгий) таймаут на каждый вызов.
EGRESS_START_TIMEOUT_S=3.0
# --- LiveKit Egress (deploy/docker-compose.yml, сервис egress; см. также
# deploy/egress/egress.yaml) — WS-адрес LiveKit ИЗНУТРИ docker-сети, отдельно
# от LIVEKIT_URL/LIVEKIT_PUBLIC_URL выше (те — для backend/браузера). В
# backend/core/config.py не читается — потребитель только egress. ---
LIVEKIT_WS_URL=ws://livekit:7880

8
.gitignore vendored
View File

@@ -54,3 +54,11 @@ backend/media/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут.
.claude/
# Wasm-рантайм MediaPipe — копируется из node_modules плагином `mediapipeWasm`
# (frontend/vite.config.ts), ~19 МБ, в репозитории ему не место.
frontend/public/mediapipe/wasm/
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
frontend/bg-bench.html
frontend/bg-support.html

View File

@@ -3,6 +3,446 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.37] — 2026-08-10
Закрытие технического долга. Без изменений в продуктовой логике, кроме
исправления лишнего отказа при первом входе в комнату (см. «Исправлено»).
### Исправлено
- Тесты бэкенда были не изолированы от состояния `instance_settings`
(`chat`, `hand_queue` и др.) в общей dev-БД: выключение любого модуля в
админке локального стенда роняло не связанные с ним тесты. Изоляция —
через фикстуру `clean_instance_settings`, явно подключаемую в тестах,
которым нужен «чистый стол» по умолчанным значениям.
- `RoomPage.tsx`: сохранённый выбор устройства «по умолчанию»
(`usePersistentUserChoices`, литерал `"default"`) не отсекался
`|| undefined` — в Chrome это давало лишний `OverconstrainedError` на
первом визите (то же самое уже было исправлено для страницы проверки
устройств в 0.0.33).
- Локальный контейнерный стенд (`docker compose --profile media up`) не
поднимался «как есть» — `DATABASE_URL` из корневого `.env` (указывает на
`localhost`, нужен только для запуска backend на хосте) утекал внутрь
контейнеров и ronял подключение к БД. Теперь compose всегда собирает
строку подключения сам из `POSTGRES_USER`/`PASSWORD`/`DB` с хостом
`postgres`, как уже было сделано для `REDIS_URL`.
- `vidconf_pipeline_sessions` в `/metrics` читалась через основной пул БД —
на нагрузке могла отвалиться вместе с остальным API ровно тогда, когда
метрика нужнее всего (см. инцидент 07.08). Теперь читается через отдельный
движок, как `vidconf_db_up`/`vidconf_db_pool_*`.
### Изменено
- `.env.example`: убраны неиспользуемые `MEDIA_URL`/`VITE_API_URL`,
добавлены живые `MEDIA_ROOT`/`EGRESS_START_TIMEOUT_S`/`LIVEKIT_WS_URL`.
- 13 предупреждений `ruff` (длина строки): реальный код переформатирован,
текст регламента персональных данных вынесен в отдельный модуль
(`services/consent_policy_text.py`) — как и вёрстка email-писем, это
прозаический текст, а не код.
## [0.0.36] — 2026-08-10
Исправление к 0.0.35: замена фона не появлялась в Safari и Firefox.
### Исправлено
- Проверка поддержки замены фона была строже, чем требует библиотека, и
молча отрезала два браузера — кнопки выбора фона не было ни в превью, ни
в комнате. У библиотеки два конвейера обработки кадров: современный
(только Chrome и производные) и запасной через `canvas.captureStream()`,
который работает и в Safari, и в Firefox; проверка требовала именно
современный. Теперь условие буквально повторяет проверку самой библиотеки.
## [0.0.35] — 2026-08-10
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).
Три готовые сцены и свои картинки (до 10 штук, загружаются в профиле).
**Только на компьютере:** сегментацию силуэта считает нейросеть на каждом
кадре, и на телефоне это греет устройство, ест батарею и просаживает FPS
в самой встрече — портить основное ради украшения нельзя.
### Добавлено
- Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе. Выбранный
до входа фон применяется к публикуемому треку сразу в конференции.
- Три дефолтные сцены (офис, пляж с пальмами, космическая станция) —
собственные векторные рисунки (`design/backgrounds/`), а не фотографии
из интернета: у нарисованной сцены нет чужой лицензии, а продукт
расходится по инсталляциям, и проверять права на каждую копию некому.
- Раздел «Свои фоны» в профиле: загрузка (не больше 10 штук) и удаление.
Картинка уменьшается до 1280px по длинной стороне и переводится в WebP
прямо в браузере перед отправкой — «чтобы не грузили БД». Файлы лежат
на диске, в БД только пути, как у аватаров.
- Тумблер «Замена фона видео» в админке. Лимит на число картинок и формат
файла проверяются на сервере, а не только в интерфейсе.
### Особенности реализации
- **Модель сегментации и wasm-рантайм отдаются со своего домена.** По
умолчанию библиотека тянет их с внешних CDN (jsdelivr и
storage.googleapis.com), и в закрытом контуре фича молча не работала бы.
Модель (Apache 2.0) лежит в репозитории, рантайм копируется из
node_modules при сборке; nginx отдаёт их сжатыми и с долгим кэшем.
- Библиотека и модель грузятся **лениво**, только когда фон реально
включают: вход в конференцию не стал медленнее, основной бандл не вырос.
- «Десктоп» определяется по возможностям устройства (точный указатель +
hover + число точек касания), а НЕ по ширине окна: узкое окно на
десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет.
На мобильном кнопки нет вовсе, а не задизейбленной.
- Фон переживает выключение и включение камеры и смену устройства съёмки.
Удаление применённой сейчас картинки сбрасывает выбор на «без фона».
## [0.0.34] — 2026-08-09
Правка к проверке устройств на входе (0.0.33): превью и кнопки
камеры/микрофона теперь и на шаге «Подключиться к конференции», не только
на карточке «Как вас зовут?». Закрывает реальный пробел — авторизованный
пользователь, входящий через `/join`, карточку «Как вас зовут?» не проходит
вовсе (сразу подключение) и раньше проверку устройств не видел никогда.
### Исправлено
- Превью камеры и кнопки вкл/выкл — на шаге ввода ссылки/номера конференции,
тем же компонентом, что и на карточке гостя. Поток живёт непрерывно на
обоих шагах — переход между ними не гасит и не переоткрывает камеру.
- Состояние кнопок «войти с камерой/микрофоном» теперь доезжает до комнаты
и для гостя (`input → guest-info → комната`), и для авторизованного
пользователя (`input → connecting → комната`).
- Починена грабля с превью между шагами: `<video>` на разных шагах —
разные DOM-узлы, обычный `ref` не переживал переезд между ними (картинка
гасла в чёрный прямоугольник, хотя поток оставался жив). `videoRef` хука
стал callback-ref, переподключающим поток к новому узлу автоматически.
## [0.0.33] — 2026-08-09
Проверка устройств на входе — отключаемый модуль (по умолчанию выключен).
Раньше при первом включении микрофона/камеры уже внутри конференции у
участника всплывал системный диалог разрешения браузера; теперь его можно
пройти заранее, на странице логина и в карточке «Как вас зовут?» при
гостевом входе, вместе с живым превью камеры.
### Добавлено
- Запрос доступа к камере и микрофону на `LoginPage` и в карточке
гостевого входа `JoinPage` — привязан к первому жесту на карточке
(клик/тап/клавиша), не спрашивается повторно, если браузер уже помнит
разрешение (`navigator.permissions`, с фолбэком на жест там, где API
недоступен, например в Safari). Камера и микрофон запрашиваются
независимо — отказ в одном не блокирует другой.
- Живое зеркальное превью камеры на `JoinPage` (карточка «Как вас
зовут?») с кнопками-пиктограммами «микрофон»/«камера»: реально
останавливают и перезапускают поток, а не просто прячут картинку.
На `JoinPage` кнопки определяют, с чем гость войдёт в конференцию
(передаётся в `RoomPage`); на `LoginPage` — только пре-авторизация
разрешения без интерфейса (страница ведёт в лобби, а не в конкретную
конференцию, применить выбор там негде).
- Публичный `GET /api/v1/public/settings` — флаг модуля, нужный обеим
страницам до аутентификации.
- Тумблер «Проверка устройств на входе» в админке (Настройки → Модули).
### Изменено
- Отказ в доступе не блокирует ни вход в систему, ни в конференцию —
показывается понятная подсказка, дальше можно идти как раньше.
- Камера гарантированно освобождается при уходе с карточки, сабмите
формы, размонтировании и любой ошибке — включая явный `release()`
перед переходом в комнату, чтобы устройство не досталось LiveKit
«занятым».
## [0.0.32] — 2026-08-09
Метрики состояния БД и пулов соединений + алерты в Prometheus — по решению
оператора на отказ БД реагируем сигналом, а не автолечением (перезапуск
контейнера при недоступной БД оборвал бы WS у всех, кто в конференциях).
См. разбор инцидента 07.08.2026 (0.0.31): `/api/health` во время отказа
отдавал 200 с `db: false`, а Prometheus скрейпит `/metrics`, где метрик
состояния БД не было вообще — строить алерт было не на чем.
### Добавлено
- **`vidconf_db_up`** — доступность БД (1/0), проверяется отдельным от
основного пула соединением с коротким таймаутом. Позволяет отличить
«БД лежит» от «основной пул занят под нагрузкой» — это два разных
состояния, и до этого релиза их нечем было различить.
- **`vidconf_db_pool_size`/`_max_overflow`/`_checked_out`** — конфигурация
и занятость основного пула SQLAlchemy. Читаются синхронно из объекта
пула (`engine.pool`), без единого запроса к БД — это единственный
способ получить сигнал именно в момент, когда пул исчерпан.
- **`vidconf_redis_pool_in_use`/`_max_connections`** — занятость пула
Redis (второй потолок того же рода, закрыт в 0.0.31).
- Алерты `deploy/monitoring/alerts.yml` (группа `vidconf-db`):
`DatabaseUnavailable` (`vidconf_db_up == 0`, `for: 30s`, critical) и
`DbConnectionPoolNearExhaustion`/`RedisConnectionPoolNearExhaustion`
(занято > 80% дольше минуты, warning) — ранний сигнал: в инциденте
07.08 пул заполнялся постепенно по мере входа участников в комнату,
а не рывком от HTTP-нагрузки.
- Дашборд Grafana **«БД и пулы соединений»**
(`deploy/monitoring/grafana/dashboards/db-pool.json`).
### Технические детали
- `GET /metrics` больше не падает и не виснет при недоступности основного
пула БД: gauge'и о состоянии пула читаются первыми и не зависят от него
(отдельное NullPool-соединение для `db_up`, синхронный снимок для
занятости пула), а зависящий от основного пула `vidconf_pipeline_sessions`
обёрнут таймаутом (2с) — при недоступности оставляет прежнее значение,
не роняя остальные метрики. Полностью развести его с основным пулом не
стали: тестовый харнесс подменяет `get_session` на savepoint-сессию
(`tests/conftest.py`), отдельное соединение не увидело бы несознанные
тестом данные — тот же компромисс, что и в 0.0.31 для `api/chat.py`.
- Проверено вживую на локальном стенде (не только по синтаксису конфига):
остановка Postgres → `vidconf_db_up` = 0, `/metrics` продолжает отвечать,
алерт `DatabaseUnavailable` переходит в `firing`; временно урезанный
пул под нагрузкой → `DbConnectionPoolNearExhaustion` переходит в
`firing` ровно через заявленный `for: 1m`; снятие нагрузки/восстановление
БД — алерты гаснут.
## [0.0.31] — 2026-08-09
Разбор провала входа на нагрузочном тесте 07.08.2026: комната держала
соединения с БД и Redis на каждого участника.
### Исправлено
- **Вход в систему переставал работать, когда в конференции набиралось
около сорока человек.** WS-подключение комнаты (чат и очередь рук)
держало занятым одно соединение с БД всё время, пока участник сидел
в конференции: SELECT'ы хендшейка открывали транзакцию, а закрыть её
было некому. Пул — 20 соединений на воркер (40 на инстанс), поэтому
сороковой вошедший выгребал его досуха, и все остальные запросы —
резолв конференции, гостевой вход, логин, обновление токена — начинали
отвечать 500. Теперь соединение возвращается в пул сразу после
хендшейка; на локальном стенде 120 участников на одном воркере не
занимают ни одного соединения в простое (было: 20 из 20 при 20
участниках, дальше вход не работал вовсе).
- **Пользователя выкидывало из системы, когда серверу было плохо.**
Фоновое обновление access-токена считало неудачей любой отрицательный
ответ и на каждую такую неудачу сбрасывало сессию с переходом на
страницу входа. Ответ 5xx (и обрыв сети) теперь означает «сервер
временно недоступен»: сессия сохраняется, пользователь остаётся
в системе и получает обычную ошибку запроса. Разлогинивание осталось
только там, где backend прямо сказал, что сессия недействительна.
Восстановление сессии при старте приложения повторяет попытку трижды,
прежде чем показать страницу входа.
### Технические детали
- Размер пула соединений с Redis задан явно (`REDIS_MAX_CONNECTIONS`,
по умолчанию 500): на нём висят долгоживущие pub/sub-подписки комнаты —
по одной на участника, — а дефолт redis-py 8 (100) упирался в потолок
примерно на сотом участнике на воркер. Второй потолок того же рода,
что и пул БД; найден при проверке правки выше на 120 участниках.
- Размеры пулов БД (`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`) не менялись
осознанно: соединение больше не удерживается впустую, поэтому
расширение пула лечило бы симптом и лишь отодвинуло порог.
## [0.0.30] — 2026-08-04
Согласие на обработку персональных данных при регистрации + отключаемый модуль.
### Добавлено
- На форме регистрации — галочка согласия на обработку персональных данных
со ссылкой на публичную страницу регламента (`/legal/personal-data-consent`).
Кнопка регистрации неактивна, пока галочка не отмечена; сервер тоже
отказывает без согласия (`POST /auth/register` → 400 `consent_required`,
второй эшелон проверки — тот же принцип, что у `hand_queue_disabled`).
- Текст регламента — настройка инстанса, редактируемая в админке
(вкладка «Настройки» → карточка «Согласие на обработку персональных
данных»): текстовое поле + тумблер «требовать согласие при регистрации».
Дефолтный текст — типовой шаблон с плейсхолдерами под организацию
(наименование оператора, адрес, контакты, цели и срок обработки),
**не проходил юридическую проверку** — в карточке администратора
об этом явное предупреждение.
- Номер редакции текста растёт автоматически при каждой правке —
у каждого пользователя, давшего согласие, в БД фиксируется и версия
документа, и дата согласия (`users.consent_version`, `consent_given_at`).
- Модуль отключаем (`instance_settings.consent_policy`), по умолчанию
выключен — поведение существующих инсталляций не меняется. У уже
зарегистрированных пользователей согласие не запрашивалось и не
запрашивается задним числом, вход не блокируется.
### Технические детали
- Миграция Alembic добавляет `users.consent_version`/`consent_given_at`
(nullable — `NULL` означает «согласие не запрашивалось»).
- Публичный `GET /auth/registration-options` (уже существующий, без нового
эндпоинта) дополнен полями `consent_required`/`consent_text`/`consent_version`
тем же ответом пользуется и страница регламента, доступная всегда,
независимо от того, включён ли модуль.
## [0.0.29] — 2026-08-04
Кнопка «демонстрация экрана» в мини-окне конференции.
### Добавлено
- В мини-окне (Document PiP) рядом с кнопкой микрофона появилась кнопка
демонстрации экрана: начать и остановить показ можно, не разворачивая
основное окно. Кнопка и кнопка основного тулбара отражают одно
состояние — обе читают его из комнаты, а не из разметки.
- Своя демонстрация показывается в мини-окне по обычным правилам сцены:
забирает крупную плитку при старте, держится, пока говорят другие, и
уступает говорящему либо закреплённому участнику после остановки.
### Примечания
- Кнопка есть только там, где мини-окно — настоящее окно со своей
разметкой, то есть в Chrome и Edge. В Safari мини-окно выводится
средствами системы (нативный «картинка в картинке»), собственных
кнопок в нём быть не может; в Firefox мини-окна нет вовсе. Это
ограничение браузеров, а не недоработка.
- При выборе «весь экран» мини-окно попадает в собственную
демонстрацию — как и у всех остальных участников. Чтобы этого
избежать, показывайте конкретное окно, а не экран целиком.
## [0.0.28] — 2026-08-04
Очередь поднятых рук видна всем участникам + отключаемый модуль.
### Добавлено
- Очередь поднятых рук больше не спрятана от обычных участников — её
видит любой, кто в конференции, не только организатор. Опустить чужую
руку по-прежнему может только организатор: сервер это уже проверял
(`api/chat.py`, задача B1), правки — только на фронте, кнопка
«Опустить» показывается у записи, если это своя рука либо пользователь
сам организатор.
- «Поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый модуль
в админке (`instance_settings.hand_queue`, дефолт `enabled=true`
поведение существующих инсталляций не меняется). Настройка едет
участнику в `JoinOut` (как `chat_enabled`) до входа в комнату.
Выключенный модуль гасит кнопки на фронте и отклоняет
`raise_hand`/`lower_hand` на сервере кодом `hand_queue_disabled`
вторая линия защиты для клиента со старым `JoinOut` на руках.
Переключение применяется со следующего входа в комнату (та же
застылость на время жизни соединения, что и у `chat_enabled`).
## [0.0.27] — 2026-08-04
Разрыв связи больше не выбрасывает участника из конференции.
### Исправлено
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
мог оказаться в лобби вместо комнаты. Причина установлена по логам
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
соединения (замерено: 37 с после блокировки экрана, 23 с после
сворачивания браузера — то есть достаточно просто убрать вкладку в
фон, гасить экран не обязательно). Восстановить сессию после этого
нельзя — участника на сервере уже нет; нужен полный повторный вход,
и `livekit-client` пытается сделать его сам, но его бюджет повторов
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
приходило событие `Disconnected`, и страница комнаты уводила
пользователя в лобби.
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
состояние входа, и работает уже имевшийся путь авто-перезахода —
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
выгнал организатор, конференция закрыта, вход той же личностью с
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
## [0.0.26] — 2026-08-04
Мобильная комната: свайп шторки настроек и полноэкранный режим.
### Исправлено
- Шторка «Настройки» на мобильном не закрывалась свайпом вниз почти никогда:
обработчики висели только на ручке-волоске (`.room-sheet-handle`,
40×4px) — палец в неё практически невозможно попасть, и палец почти
всегда приземлялся на панель, где обработчиков не было вовсе. Теперь
свайп закрывает шторку при жесте по любому месту панели, но только
когда её содержимое проскроллено в самый верх (`scrollTop === 0` на
начало жеста) — иначе свайп вниз листает список устройств, как и должен
стандартный bottom sheet.
### Добавлено
- Полноэкранный режим комнаты — на мобильном кнопка «Экран» в тулбаре (как
на десктопе), при активации топбар и тулбар уходят из потока и лежат
оверлеем поверх сцены: показываются по тапу/клику по сцене вне элементов
управления и сами прячутся через несколько секунд бездействия. На
десктопе — та же логика (топбар/тулбар тоже прячутся в полноэкранном
режиме), дополнительный способ вернуть их — навести мышь в нижнюю полосу
экрана.
### Изменено
- Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства».
- Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд
(7 и больше — с полноэкранным режимом и «Очередью» у организатора это
обычный случай), стала равномерной сеткой на 4 колонки (7 → 4+3,
8 → 4+4) вместо переноса «как получится» через `flex-wrap`.
- `deploy/render-templates.sh` умеет читать значения из файла, заданного
переменной `ENV_FILE`, а не только из корневого `.env` (см. коммит
`3847476`, вошёл в этот релиз) — для локальных стендов, где корневой
`.env` указывает на боевые адреса.
## [0.0.25] — 2026-08-03
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.
### Исправлено
- В Chrome пропадал звук других участников после возврата сцены из
мини-окна в основное. `RoomAudioRenderer` жил внутри `RoomStage` и
рендерился в обеих её ветках, поэтому скрытые `<audio>` с чужими
аудиотреками физически переезжали в документ PiP-окна и обратно.
После такого переезда Chrome теряет аудиовыход у remote-трека: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration` и `totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же цикл
detach/attach в пределах одного документа безвреден — дело именно в
переезде между документами. Рендерер вынесен в `RoomPage`: один
экземпляр, всегда в основном документе, в PiP-окне аудиоэлементов нет
вовсе. В Safari бага не было — там Document PiP не используется
(video-фолбэк), сцена из основного окна не уезжает.
- В мини-окне не действовало закрепление участника, а демонстрация
экрана слетала на говорящего от любой чужой реплики. Мини-плеер
намеренно ходил с упрощёнными правилами выбора фокуса (без
`holdScreenShare`, без приоритета говорящего с камерой, без
антидребезга) и с собственным локальным состоянием закрепления.
Теперь `pickStageFocus` получает одинаковые правила в обоих вариантах
сцены, а закрепление переезжает между окнами тем же мостиком через
`RoomPage`, что и фокус. Сознательно оставлено одно отличие —
фолбэк «показать себя» (`localKey`) только у мини-плеера: это защита
от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.
- Закрепление сбрасывалось при каждом монтировании сцены: на первом
рендере `useTracks` отдаёт пустой набор треков, и правило «закреплённый
вышел из комнаты» принимало это за уход участника.
### Изменено
- Кнопка-булавка на плитке подписана «Закрепить» вместо «Закрепить в
основном окне» — закрепление больше не ограничено основным окном.
## [0.0.24] — 2026-08-03
Три артефакта вёрстки, вылезающие за границы блоков (эмодзи-поповер чата,
заголовок брендовой панели, чат в Firefox) + аудит похожих мест.
### Исправлено
- Заголовок «Ваша инфраструктура» на странице входа вылезал за край
брендовой панели на широком окне с узкой панелью (`.layout` — flex
42/58) — старый фикс уменьшал кегль только по ширине ОКНА (`@media`),
а не панели. Кегль `.brand-headline` теперь считается через container
query (`container-type: inline-size` + `cqw`) — тот же приём, что у
аватара участника в комнате. Тем же механизмом обрезались плашки
статистики («AI-саммари») — `.brand-stats` теперь переносит их на
мобильном и десктопе одинаково.
- Эмодзи-поповер в чате комнаты: последняя (5-я) колонка вылезала за
правый край поповера. Причина — гонка CSS-специфичности: правило
круглой кнопки «Отправить» (`.chat-input-row button`, 42×42px)
продолжало красить размер и кнопкам эмодзи внутри поповера (та же
гонка чинилась для цвета в 0.0.19, но не для размера). Добавлены явные
`width`/`height: auto` нужной специфичности + `max-width` на попап как
общая страховка.
- Чат комнаты вылезал за границы панели в Firefox: `.chat-messages`
(flex-колонка) без `min-height: 0` не сжималась в Firefox, `textarea`
поля ввода без `min-width: 0` упиралась в автоматическую минимальную
ширину (Firefox считает её от атрибута `cols`, жёстче Chrome/Safari).
## [0.0.23] — 2026-08-02
Документация по сайзингу под медиа-нагрузку + видимость TURN-аллокаций в логах.
### Добавлено
- `docs/deploy/hardware-sizing.md` — таблица «профиль нагрузки → CPU/RAM/
полоса» для медиа (видеоконференции), на реальных боевых замерах
28.07 и 31.07.2026, с формулой для расчёта под свой сценарий.
### Исправлено
- coturn: включено verbose-логирование — дефолтный уровень не писал
построчно `ALLOCATE`/`CreatePermission`/`Refresh` даже при рабочем
relay-соединении (найдено на релизе 0.0.22 — звонок через TURN работал,
а `grep -ci allocate` по логам coturn показывал 0).
## [0.0.22] — 2026-08-02
TURN over TLS (5349) — для клиентов из сетей, где наружу открыт только 443.

View File

@@ -264,6 +264,8 @@ VidConf использует единый инсталлятор `install.sh` с
```
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
### Docker Compose профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.22
0.0.37

View File

@@ -0,0 +1,41 @@
"""user consent to personal data processing
Согласие на обработку персональных данных при регистрации (сессия 30):
- `users.consent_version` — редакция регламента (`instance_settings.consent_policy.version`),
с которой согласился пользователь;
- `users.consent_given_at` — момент согласия.
Оба поля nullable: у существующих пользователей и у зарегистрированных при
выключенном модуле согласие не запрашивалось — `NULL` трактуется как
«согласие не запрашивалось», вход таким пользователям не блокируется.
Revision ID: 4d08a44ad153
Revises: d87681e12784
Create Date: 2026-08-04 21:41:33.813206
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4d08a44ad153'
down_revision: Union[str, Sequence[str], None] = 'd87681e12784'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('users', sa.Column('consent_version', sa.Integer(), nullable=True))
op.add_column(
'users', sa.Column('consent_given_at', sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('users', 'consent_given_at')
op.drop_column('users', 'consent_version')

View File

@@ -0,0 +1,61 @@
"""user virtual background images
Свои картинки пользователя для замены фона видео (сессия 35):
- таблица `user_backgrounds` — id, владелец (`ON DELETE CASCADE`), путь к файлу
относительно `MEDIA_ROOT`, время загрузки.
Сами файлы лежат на диске в томе `media` (`backgrounds/{user_id}/{id}.{ext}`),
в БД только путь — как у аватаров (`users.avatar_path`). Лимит «не более 10
картинок на пользователя» — политика продукта, проверяется в
`services/backgrounds.py`, а не ограничением БД.
Настройка отключаемого модуля (`instance_settings.virtual_background`)
миграции не требует: `instance_settings` — key-value JSONB, новая настройка
это новая строка (см. `services/instance_settings.py`).
Revision ID: a37c1b9e0f42
Revises: 4d08a44ad153
Create Date: 2026-08-09 23:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a37c1b9e0f42'
down_revision: Union[str, Sequence[str], None] = '4d08a44ad153'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'user_backgrounds',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
server_default=sa.text('gen_random_uuid()'),
nullable=False,
),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('path', sa.String(length=512), nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False
),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
'ix_user_backgrounds_user_created', 'user_backgrounds', ['user_id', 'created_at']
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index('ix_user_backgrounds_user_created', table_name='user_backgrounds')
op.drop_table('user_backgrounds')

View File

@@ -62,6 +62,7 @@ from services.email import EmailSendError, create_email_backend
from services.instance_settings import (
InstanceSettingsService,
InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
@@ -404,6 +405,7 @@ async def update_settings(
InvalidTimezoneError,
InvalidEmailDomainError,
InvalidContactEmailError,
InvalidConsentPolicyError,
) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
@@ -456,6 +458,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut(
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg),
@@ -469,6 +472,11 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
contact_email=cfg.contact_email,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
consent_required=cfg.consent_required,
consent_policy_text=cfg.consent_policy_text,
consent_policy_version=cfg.consent_policy_version,
device_check_enabled=cfg.device_check_enabled,
virtual_background_enabled=cfg.virtual_background_enabled,
)

View File

@@ -21,6 +21,7 @@ from schemas.auth import (
)
from services.auth import (
AuthService,
ConsentRequiredError,
EmailAlreadyRegisteredError,
EmailNotVerifiedError,
InvalidCredentialsError,
@@ -63,7 +64,12 @@ async def registration_options(
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains
team_choice_enabled=cfg.registration_team_choice,
teams=teams,
email_domains=email_domains,
consent_required=cfg.consent_required,
consent_text=cfg.consent_policy_text,
consent_version=cfg.consent_policy_version,
)
@@ -78,6 +84,7 @@ async def register(
name_user=data.name_user,
password=data.password,
team_id=data.team_id,
consent_accepted=data.consent_accepted,
)
except EmailAlreadyRegisteredError as exc:
raise HTTPException(
@@ -91,6 +98,10 @@ async def register(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
) from exc
except ConsentRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="consent_required"
) from exc
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -71,6 +71,7 @@ async def chat_websocket(
except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code)
return
hand_queue_enabled = await service.hand_queue_enabled()
pubsub = redis_client.pubsub()
channel = chat_channel(conference.id)
@@ -86,6 +87,26 @@ async def chat_websocket(
await pubsub.subscribe(channel, room_channel)
try:
history = await service.history(conference)
# 🔑 Вернуть соединение с БД в пул ДО входа в долгоживущие насосы.
#
# Хендшейк выше сделал несколько SELECT'ов (тоггл чата, конференция,
# тоггл рук, история) — SQLAlchemy открыла транзакцию на первом же из
# них и держала бы её, а с ней и соединение из пула, ВСЁ время жизни
# WS: участник сидит в комнате час — час занято соединение. Пул это
# `db_pool_size + db_max_overflow` на воркер (10 + 10), то есть
# 40 на инстанс из двух воркеров, и сороковой вошедший выгребал его
# досуха: `pg_stat_activity` показывал 40 соединений
# `idle in transaction` при одном `active`, а посторонние ручки
# (резолв, гостевой вход, логин, refresh) начинали падать в
# `QueuePool limit ... timed out` и отдавать 500. Ровно это положило
# вход на нагрузочном тесте 07.08.2026 при ~50 участниках.
#
# Соединение здесь больше не нужно: оба насоса ниже работают через
# Redis, а единственная запись в БД (`persist_and_publish`) сама
# открывает транзакцию и коммитит её, освобождая соединение сразу.
# ⚠️ Любое чтение из БД, добавленное между этой строкой и концом
# обработчика, обязано так же завершаться commit/rollback.
await session.commit()
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history}
@@ -94,7 +115,11 @@ async def chat_websocket(
async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
tg.create_task(
_pump_websocket_to_service(
websocket, service, conference, identity, hand_queue_enabled
)
)
except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка.
pass
@@ -163,9 +188,20 @@ async def _pump_pubsub_to_websocket(
async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
websocket: WebSocket,
service: ChatService,
conference: Conference,
identity: ChatIdentity,
hand_queue_enabled: bool,
) -> None:
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать."""
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
линия защиты сверх того, что фронт при выключенном модуле вообще не
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
"""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True:
raw = await websocket.receive_text()
@@ -178,11 +214,21 @@ async def _pump_websocket_to_service(
if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn):
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name
)
await hand_queue.publish_snapshot(conference.id)
else:
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer:
await websocket.send_json(

View File

@@ -1,4 +1,4 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа.
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
@@ -12,8 +12,24 @@ Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
Redis) можно опросить обычным `await` вместо реализации синхронного
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник
и вовсе синхронный — настройки уже в памяти процесса).
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
`vidconf_db_pool_*`, `vidconf_pipeline_sessions`) обязаны читаться БЕЗ
обращения к самому пулу — иначе в момент его исчерпания (см.
`.forcc/session-results/32-loadtest-07-08-debug.md`) эндпоинт метрик падал бы
вместе со всем остальным ровно тогда, когда нужнее всего. `vidconf_db_pool_*`
— синхронный снимок `engine.pool` (см. `core/db.py::db_pool_stats`),
`vidconf_db_up` — отдельное соединение вне основного пула
(`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` с сессии 37
тоже читает через отдельный движок (`Depends(get_metrics_session)`,
`core/db.py`) — тестовый харнесс подменяет её на savepoint-сессию теста так
же, как `get_session` (см. `tests/conftest.py`). До сессии 37 она ходила
через основной пул (`Depends(get_session)`) — прецедент `f7c4fb4`/session 32;
дополнительная обёртка таймаутом и try/except ниже осталась как вторая
линия обороны на случай, если сама БД (а не пул) не отвечает.
"""
import asyncio
import time
from collections.abc import Awaitable, Callable
@@ -23,7 +39,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match
from core.config import get_settings
from core.db import get_session
from core.db import check_db_up, db_pool_checked_out, get_metrics_session
from core.redis import redis_client
from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository
@@ -80,9 +96,30 @@ PIPELINE_SESSIONS = Gauge(
)
# Сколько ждать основной пул под этой конкретной метрикой, прежде чем
# сдаться и оставить прежнее значение gauge. Меньше `db_pool_timeout` (10с,
# `core/config.py`) — Prometheus скрейпит раз в 15с, и эта метрика не должна
# в одиночку съедать бюджет всего окна scrape.
_PIPELINE_GAUGE_TIMEOUT_S = 2.0
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`."""
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
`session` — из `Depends(get_metrics_session)` (отдельный от основного
пула движок, см. докстринг модуля и `core/db.py`). Таймаут и try/except
ниже — вторая линия обороны на случай, если недоступна сама БД (а не
только основной пул): запрос не должен держать весь `/metrics`, gauge
остаётся на прежнем значении (не обнуляется — обнулять его при
недоступности БД так же неверно, как считать сеансы пропавшими).
"""
try:
counts = await asyncio.wait_for(
ConferenceSessionRepository(session).count_by_pipeline_status(),
timeout=_PIPELINE_GAUGE_TIMEOUT_S,
)
except Exception: # noqa: BLE001
return
for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
@@ -113,6 +150,70 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- Доступность БД и занятость основного пула (сессия 33) -----------------
#
# Ранний сигнал важнее самого факта отказа: в инциденте 07.08 пул заполнялся
# постепенно (`idle in transaction` 3→8→16→26→35→39→40 участников) —
# `vidconf_db_pool_checked_out` показал бы это задолго до первого 500.
# Обе метрики читаются без обращения к основному пулу (см. докстринг модуля
# и `core/db.py`), поэтому доступны и в момент, когда сам пул исчерпан.
DB_UP = Gauge(
"vidconf_db_up",
"Доступность БД (1/0) — проверяется отдельным соединением вне основного пула",
)
DB_POOL_SIZE = Gauge(
"vidconf_db_pool_size",
"Настроенный размер основного пула БД без overflow (db_pool_size)",
)
DB_POOL_MAX_OVERFLOW = Gauge(
"vidconf_db_pool_max_overflow",
"Настроенный максимум overflow-соединений сверх db_pool_size (db_max_overflow)",
)
DB_POOL_CHECKED_OUT = Gauge(
"vidconf_db_pool_checked_out",
"Число соединений основного пула БД, занятых прямо сейчас (в пуле + overflow)",
)
async def _refresh_db_up_gauge() -> None:
"""Пересчитать `vidconf_db_up` отдельным от основного пула соединением."""
DB_UP.set(1 if await check_db_up() else 0)
def _refresh_db_pool_gauges() -> None:
"""Пересчитать gauge'и занятости основного пула — синхронно, без I/O."""
settings = get_settings()
DB_POOL_SIZE.set(settings.db_pool_size)
DB_POOL_MAX_OVERFLOW.set(settings.db_max_overflow)
DB_POOL_CHECKED_OUT.set(db_pool_checked_out())
# --- Занятость пула Redis (сессия 33, второй потолок из session 32) --------
#
# Тот же класс отказа, что и у пула БД: каждое WS-подключение комнаты держит
# pub/sub-соединение всё время, пока участник в конференции (`core/redis.py`,
# `redis_max_connections`). Снимок — синхронный (атрибуты пула в памяти
# процесса redis-py), Redis для этого спрашивать не нужно.
REDIS_POOL_IN_USE = Gauge(
"vidconf_redis_pool_in_use",
"Число занятых соединений пула Redis прямо сейчас",
)
REDIS_POOL_MAX = Gauge(
"vidconf_redis_pool_max_connections",
"Настроенный максимум соединений пула Redis (redis_max_connections)",
)
def _refresh_redis_pool_gauges() -> None:
"""Пересчитать gauge'и занятости пула Redis — синхронно, без I/O."""
pool = redis_client.connection_pool
REDIS_POOL_IN_USE.set(len(pool._in_use_connections)) # noqa: SLF001
REDIS_POOL_MAX.set(pool.max_connections)
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
HOST_INFO = Gauge(
@@ -150,7 +251,7 @@ def _refresh_host_info_gauge() -> None:
@router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response:
async def metrics_endpoint(session: AsyncSession = Depends(get_metrics_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
@@ -158,7 +259,16 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
`_refresh_db_pool_gauges`) считаются первыми — они гарантированно попадут в
ответ, даже если следующая за ними `_refresh_pipeline_sessions_gauge`
(отдельный движок, но всё ещё сама БД) зависнет или упадёт (таймаут/
try-except внутри неё гасят это, не роняя остальные метрики).
"""
await _refresh_db_up_gauge()
_refresh_db_pool_gauges()
_refresh_redis_pool_gauges()
await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge()
_refresh_host_info_gauge()

24
backend/api/public.py Normal file
View File

@@ -0,0 +1,24 @@
"""Роутер публичных настроек клиента — доступен без аутентификации."""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from schemas.public import PublicSettingsOut
from services.instance_settings import InstanceSettingsService
router = APIRouter(prefix="/api/v1/public", tags=["public"])
@router.get("/settings", response_model=PublicSettingsOut)
async def public_settings(
session: Annotated[AsyncSession, Depends(get_session)],
) -> PublicSettingsOut:
"""Флаги инстанса, нужные публичным страницам логина/входа гостя до аутентификации."""
cfg = await InstanceSettingsService(session).get()
return PublicSettingsOut(
device_check_enabled=cfg.device_check_enabled,
virtual_background_enabled=cfg.virtual_background_enabled,
)

View File

@@ -1,5 +1,6 @@
"""Роутер профиля текущего пользователя, аватара и списка пользователей."""
"""Роутер профиля текущего пользователя, аватара, картинок фона и списка пользователей."""
import uuid
from pathlib import Path
from typing import Annotated
@@ -11,9 +12,27 @@ from core.config import get_settings
from core.db import get_session
from core.security import hash_password, verify_password
from models.user import User
from models.user_background import UserBackground
from repositories.users import UserRepository
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut
from schemas.auth import (
PasswordChangeIn,
ProfileUpdateIn,
UserBackgroundOut,
UserBackgroundsOut,
UserListItemOut,
UserProfileOut,
)
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.backgrounds import (
MAX_BACKGROUNDS_PER_USER,
BackgroundInvalidTypeError,
BackgroundLimitReachedError,
BackgroundTooLargeError,
add_background,
background_url,
delete_background,
list_backgrounds,
)
from services.profile import (
TeamNotFoundError,
clear_avatar,
@@ -89,6 +108,60 @@ async def delete_current_user_avatar(
await session.commit()
@router.get("/me/backgrounds", response_model=UserBackgroundsOut)
async def list_current_user_backgrounds(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserBackgroundsOut:
"""Свои картинки для замены фона видео + лимит на их число."""
items = await list_backgrounds(session, user.id)
return _to_backgrounds_out(items)
@router.post(
"/me/backgrounds", response_model=UserBackgroundsOut, status_code=status.HTTP_201_CREATED
)
async def upload_current_user_background(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserBackgroundsOut:
"""Загрузить свою картинку фона (jpeg/png/webp, до 2 МБ, не более 10 штук).
Возвращает весь список заново, а не одну добавленную запись: интерфейсу всё
равно нужен свежий список с актуальным остатком лимита, и лишний GET следом
за POST не нужен.
"""
try:
await add_background(session, _media_root(), user.id, file)
except BackgroundLimitReachedError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="background_limit_reached"
) from exc
except BackgroundTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="background_too_large"
) from exc
except BackgroundInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="background_invalid_type"
) from exc
await session.commit()
return _to_backgrounds_out(await list_backgrounds(session, user.id))
@router.delete("/me/backgrounds/{background_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_background(
background_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить свою картинку фона вместе с файлом на диске; чужую — 404."""
if not await delete_background(session, _media_root(), user.id, background_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="background_not_found")
await session.commit()
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_current_user_password(
data: PasswordChangeIn,
@@ -126,6 +199,14 @@ async def list_users(
]
def _to_backgrounds_out(items: list[UserBackground]) -> UserBackgroundsOut:
"""Собрать ответ списка картинок фона: id + публичный URL, плюс лимит с сервера."""
return UserBackgroundsOut(
items=[UserBackgroundOut(id=item.id, url=background_url(item.path)) for item in items],
limit=MAX_BACKGROUNDS_PER_USER,
)
def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_settings().media_root)

View File

@@ -38,6 +38,27 @@ class Settings(BaseSettings):
# и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10
# --- Проверка доступности БД вне основного пула (`core/db.py::check_db_up`) ---
# Таймаут TCP/auth отдельного соединения-пробы (не путать с
# `db_pool_timeout` выше — тот про очередь на основной пул). Дефолт
# asyncpg — 60с, для сигнала мониторинга это неприемлемо долго: пусть
# `vidconf_db_up` станет 0 за секунды, а не через минуту.
db_probe_timeout_s: float = 3.0
# --- Пул соединений с Redis ---
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
# человек сидит в конференции, — и берёт его из этого же пула, что и
# обычные команды. redis-py 8 поставил дефолт `max_connections=100`
# (раньше предел был условно бесконечным), поэтому сотый участник на
# воркер выгребал пул досуха и WS падал уже на `hgetall` очереди рук —
# воспроизведено локально при 99 одновременных подключениях.
# 500 — с запасом на инстанс, рассчитанный на пару сотен участников
# на воркер; соединения создаются по мере надобности, само по себе
# значение ничего не стоит. Потолок сверху — `maxclients` у Redis
# (дефолт 10000) на ВСЕ процессы вместе, включая Celery-воркеры.
redis_max_connections: int = 500
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового

View File

@@ -1,13 +1,16 @@
"""Настройка асинхронного движка SQLAlchemy и сеанса."""
from collections.abc import AsyncGenerator
from typing import cast
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool, QueuePool
from core.config import get_settings
@@ -31,3 +34,77 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI, возвращающая `AsyncSession`."""
async with async_session_maker() as session:
yield session
# --- Проверка доступности БД вне основного пула (сессия 33) ----------------
#
# Отдельный движок с `NullPool`: каждый вызов открывает новое соединение и
# закрывает его сразу после — бюджет соединений не пересекается с
# `engine.pool` (10 + 10 overflow × число воркеров uvicorn). Это единственный
# способ отличить «БД лежит» от «основной пул занят под нагрузкой»: проверка
# через `get_session()` в момент исчерпания пула сама встала бы в очередь на
# `db_pool_timeout` и не смогла бы ответить, пока не появится случайно
# освободившееся место — то есть не отличила бы два принципиально разных
# состояния. Короткий `timeout` на соединение (не путать с `db_pool_timeout`
# основного пула) — чтобы зависший, а не оборванный TCP (Postgres отвечает,
# но не может продвинуться) не держал проверку до дефолтных 60 секунд asyncpg.
_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
async def check_db_up() -> bool:
"""`True`, если БД отвечает на `SELECT 1` по отдельному от основного пула соединению."""
try:
async with _probe_engine.connect() as connection:
await connection.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
return False
return True
# --- Сессия для метрик, читающих данные (не только «жив/мёртв»), вне основного
# пула (сессия 37, доделка session 32/33 — см. докстринг `api/metrics.py`) ----
#
# `check_db_up` выше обходится голым соединением ("SELECT 1"), но
# `vidconf_pipeline_sessions` нужна полноценная ORM-сессия (репозиторий,
# группировка по статусу) — `NullPool`, как и у `_probe_engine`: каждый вызов
# открывает новое соединение и сразу закрывает его, бюджет основного пула
# (`engine.pool`) не расходуется. `async_sessionmaker` — тот же паттерн, что
# `async_session_maker` выше, просто на другом движке.
_metrics_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
_metrics_session_maker = async_sessionmaker(_metrics_probe_engine, expire_on_commit=False)
async def get_metrics_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI для метрик, которым нужна БД, но не основной пул.
В отличие от `get_session()` (основной пул `engine.pool`, конкурирует за
те же 10+10×воркеров соединений, что и API-запросы), сессия здесь открыта
на `_metrics_probe_engine` — исчерпание основного пула эту метрику не
заденет, как и `vidconf_db_up`/`vidconf_db_pool_*`. Тестовый харнесс
(`tests/conftest.py`) подменяет и её на savepoint-сессию теста — так же,
как `get_session` — иначе тест `vidconf_pipeline_sessions` не видел бы
данные, ещё не закоммиченные за пределы savepoint.
"""
async with _metrics_session_maker() as session:
yield session
def db_pool_checked_out() -> int:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
SQLAlchemy держит счётчик в памяти самого объекта пула (`engine.pool`),
поэтому его можно прочитать в любой момент, даже когда все соединения
заняты или БД недоступна — именно это нужно алерту на исчерпание пула
(метрика не должна зависеть от того, что измеряет). Размер и лимит
overflow — конфигурация (`Settings.db_pool_size`/`db_max_overflow`),
их не нужно снимать с объекта пула отдельно.
"""
return cast(QueuePool, engine.pool).checkedout()

View File

@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
enabled: bool = True
class HandQueueConfig(BaseModel):
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
enabled: bool = True
class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig)
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig:
@@ -92,6 +99,7 @@ class InstanceConfig(BaseModel):
transcriber: TranscriberConfig
summarizer: SummarizerConfig
chat: ChatConfig
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow"
@@ -115,3 +123,24 @@ class InstanceConfig(BaseModel):
# не только в админке — настройка должна быть на руках у клиента до
# публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)
# Согласие на обработку персональных данных при регистрации: галочка
# обязательна только при `consent_required=True`, текст/версия — редактируемая
# администратором настройка (дефолт — типовой шаблон, не юридический документ) —
# см. `services/instance_settings.py`. `consent_policy_text`/`_version`
# отдаются публично (`GET /auth/registration-options`) независимо от
# `consent_required`, чтобы страница регламента была осмысленной и при
# выключенном модуле.
consent_required: bool = False
consent_policy_text: str = ""
consent_policy_version: int = 1
# Проверка устройств на входе (сессия 33): запрос доступа к микрофону/камере
# + превью камеры на странице логина и в карточке «Как вас зовут?» (JoinPage).
# Дефолт False сохраняет поведение существующих инсталляций — см.
# `services/instance_settings.py`.
device_check_enabled: bool = False
# Замена фона видео на картинку (сессия 35): отключаемый модуль, дефолт
# False сохраняет поведение существующих инсталляций. Нужен клиенту в двух
# местах и потому едет двумя путями: на публичные страницы входа — через
# `GET /public/settings`, участнику комнаты — в ответе join
# (`schemas/conferences.py::JoinOut`), см. `services/instance_settings.py`.
virtual_background_enabled: bool = False

View File

@@ -6,4 +6,11 @@ from core.config import get_settings
settings = get_settings()
redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True)
redis_client: Redis = Redis.from_url(
settings.redis_url,
decode_responses=True,
# Размер пула задаём явно: дефолт redis-py (100) рассчитан на команды, а у
# нас на нём же висят долгоживущие pub/sub-подписки комнаты — по одной на
# участника (см. `core/config.py`, `redis_max_connections`).
max_connections=settings.redis_max_connections,
)

View File

@@ -16,6 +16,7 @@ from api.health import router as health_router
from api.livekit_webhook import router as livekit_webhook_router
from api.metrics import prometheus_latency_middleware
from api.metrics import router as metrics_router
from api.public import router as public_router
from api.teams import router as teams_router
from api.users import router as users_router
from core.config import get_settings
@@ -76,6 +77,7 @@ def create_app() -> FastAPI:
app.middleware("http")(prometheus_latency_middleware)
app.include_router(health_router)
app.include_router(metrics_router)
app.include_router(public_router)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(teams_router)

View File

@@ -18,6 +18,7 @@ from models.phrase import Phrase
from models.session import ConferenceSession
from models.team import Team
from models.user import User
from models.user_background import UserBackground
from models.webhook_event import LivekitWebhookEvent
__all__ = [
@@ -36,4 +37,5 @@ __all__ = [
"SessionAudioTrack",
"Team",
"User",
"UserBackground",
]

View File

@@ -3,7 +3,17 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -35,6 +45,15 @@ class User(Base):
# Путь к загруженному аватару (относительно `MEDIA_ROOT`):
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Согласие на обработку персональных данных при регистрации: редакция
# регламента (`instance_settings.consent_policy.version` на момент
# согласия) и время. `NULL` у обоих — согласие не запрашивалось (модуль
# был выключен либо пользователь зарегистрирован до появления этой
# настройки); вход таким пользователям не блокируется.
consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
consent_given_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -0,0 +1,45 @@
"""Модель UserBackground — своя картинка пользователя для замены фона видео.
В БД хранится только путь к файлу относительно `MEDIA_ROOT`
(`backgrounds/{user_id}/{background_id}.{ext}`) — ровно тот же приём, что и у
аватаров (`users.avatar_path`, `services/avatars.py`): «чтобы не грузили БД»
(требование оператора). Сами файлы лежат на диске в томе `media`, который
nginx раздаёт напрямую по `location /media/`.
Лимит на число картинок (`MAX_BACKGROUNDS_PER_USER`) проверяется в сервисе, а
не ограничением БД: он про политику продукта, а не про целостность данных, и
администратор может захотеть его поменять.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class UserBackground(Base):
"""Загруженная пользователем картинка фона."""
__tablename__ = "user_backgrounds"
__table_args__ = (
# Выборка всегда одна и та же — «все фоны этого пользователя, свежие
# сверху» (`services/backgrounds.py::list_backgrounds`), и она же
# считает лимит при загрузке.
Index("ix_user_backgrounds_user_created", "user_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Путь относительно `MEDIA_ROOT`: `backgrounds/{user_id}/{id}.{ext}`.
path: Mapped[str] = mapped_column(String(512), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -61,6 +61,9 @@ ignore = ["B008"] # FastAPI's `Depends(...)` default-argument pattern is idioma
# большинство из них игнорирует) — длина строки не показатель качества здесь,
# оборачивать вёрстку ради line-length бессмысленно.
"services/email_templates.py" = ["E501"]
# Дефолтный текст регламента обработки персональных данных — прозаический
# шаблон документа, не код (см. докстринг модуля).
"services/consent_policy_text.py" = ["E501"]
[tool.ruff.lint.isort]
# `workers/` — соседний пакет монорепо (см. `pythonpath` в

View File

@@ -1,6 +1,7 @@
"""Репозиторий доступа к таблице `users`."""
import uuid
from datetime import datetime
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,9 +31,24 @@ class UserRepository:
name_user: str,
password_hash: str,
team_id: uuid.UUID | None = None,
consent_version: int | None = None,
consent_given_at: datetime | None = None,
) -> User:
"""Создать нового пользователя (role='user', email_verified=False по умолчанию)."""
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
"""Создать нового пользователя (role='user', email_verified=False по умолчанию).
`consent_version`/`consent_given_at` — редакция регламента обработки
персональных данных, с которой согласился пользователь, и время
согласия; `None` у обоих, если согласие не запрашивалось (модуль
выключен) — см. `services.auth.AuthService.register`.
"""
user = User(
email=email,
name_user=name_user,
password_hash=password_hash,
team_id=team_id,
consent_version=consent_version,
consent_given_at=consent_given_at,
)
self._session.add(user)
await self._session.flush()
return user

View File

@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
"""
chat_enabled: bool
hand_queue_enabled: bool
transcription_enabled: bool
ai_level: AiLevel
ai_levels: list[AiLevelStatus]
@@ -162,6 +163,15 @@ class SettingsOut(BaseModel):
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
# Согласие на обработку персональных данных при регистрации — см.
# `core/plugins/config.py::InstanceConfig`.
consent_required: bool
consent_policy_text: str
consent_policy_version: int
# Проверка устройств на входе (сессия 33) — см. `core/plugins/config.py::InstanceConfig`.
device_check_enabled: bool
# Замена фона видео (сессия 35) — см. `core/plugins/config.py::InstanceConfig`.
virtual_background_enabled: bool
class TestEmailIn(BaseModel):

View File

@@ -11,12 +11,18 @@ class RegisterIn(BaseModel):
`team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400.
`consent_accepted` обязан быть `True`, если в настройках инстанса
включено `consent_required` (согласие на обработку персональных
данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если
настройка выключена (второй эшелон проверки — фронт тоже блокирует
кнопку, но сервер не полагается на это).
"""
email: EmailStr
name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
consent_accepted: bool = False
class VerifyEmailIn(BaseModel):
@@ -80,6 +86,29 @@ class UserProfileOut(UserOut):
team_name: str | None = None
class UserBackgroundOut(BaseModel):
"""Своя картинка пользователя для замены фона видео (`GET /users/me/backgrounds`).
Отдаётся только URL файла (`/media/backgrounds/...`, раздаёт nginx) и id для
удаления — путь на диске наружу не показывается.
"""
id: uuid.UUID
url: str
class UserBackgroundsOut(BaseModel):
"""Список своих картинок фона вместе с лимитом.
Лимит приезжает с сервера, а не зашит в интерфейс: он проверяется на
сервере (`services/backgrounds.py`), и фронт не должен угадывать его
отдельной константой, которая разъедется при первой же правке.
"""
items: list[UserBackgroundOut]
limit: int
class PasswordChangeIn(BaseModel):
"""Тело смены пароля текущим пользователем (`POST /users/me/password`).
@@ -111,3 +140,10 @@ class RegistrationOptionsOut(BaseModel):
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domains: list[str] = Field(default_factory=list)
# Согласие на обработку персональных данных: `consent_required` — обязательна
# ли галочка на форме регистрации; `consent_text`/`consent_version` отдаются
# ВСЕГДА, независимо от `consent_required` — той же строкой пользуется
# публичная страница регламента, доступная и при выключенном модуле.
consent_required: bool = False
consent_text: str = ""
consent_version: int = 1

View File

@@ -130,11 +130,19 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
hand_queue_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
# Тоггл инстанса `virtual_background.enabled` — гасит замену фона целиком
# (кнопка в тулбаре комнаты и применение процессора к треку). Едет здесь,
# а не через `/admin/settings` (доступен только админу): значение нужно
# участнику — в том числе гостю — до первого рендера комнаты.
virtual_background_enabled: bool
class ConferenceOut(BaseModel):

23
backend/schemas/public.py Normal file
View File

@@ -0,0 +1,23 @@
"""Схемы публичного эндпоинта настроек клиента (`GET /public/settings`)."""
from pydantic import BaseModel
class PublicSettingsOut(BaseModel):
"""Настройки инстанса, нужные клиенту ДО аутентификации.
Общая точка для флагов, которые должны быть на руках у страницы логина
и гостевой карточки входа (`LoginPage`/`JoinPage`) — обе публичные,
`GET /admin/settings` им недоступен (только для админа). Отдельно от
`GET /auth/registration-options`: тот про опции конкретно карточки
регистрации, а не про настройки инстанса в целом (сессия 33).
"""
# Проверка устройств на входе (сессия 33) — см.
# `core/plugins/config.py::InstanceConfig.device_check_enabled`.
device_check_enabled: bool
# Замена фона видео (сессия 35) — нужен превью на `JoinPage`, чтобы
# показать выбор фона ещё до входа в комнату. Участнику УЖЕ в комнате тот
# же флаг приезжает в join-ответе (`JoinOut.virtual_background_enabled`):
# эта страница публичная и `GET /admin/settings` ей недоступен.
virtual_background_enabled: bool

View File

@@ -58,6 +58,15 @@ class InvalidEmailDomainError(Exception):
"""
class ConsentRequiredError(Exception):
"""Согласие на обработку персональных данных не отмечено.
Поднимается только при включённой настройке инстанса `consent_required`
(см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
даёт отправить форму без галочки, но сервер не полагается на это.
"""
class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован."""
@@ -99,6 +108,7 @@ class AuthService:
name_user: str,
password: str,
team_id: uuid.UUID | None = None,
consent_accepted: bool = False,
) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email.
@@ -109,7 +119,12 @@ class AuthService:
email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя.
`InvalidEmailDomainError`. Если включено согласие на обработку
персональных данных (`consent_required`), `consent_accepted` обязан
быть `True` — иначе `ConsentRequiredError`; при принятии согласия
в `User` пишутся `consent_version`/`consent_given_at` (редакция
регламента на момент согласия и время). Все проверки — до создания
пользователя.
"""
existing = await self._users.get_by_email(email)
if existing is not None:
@@ -129,11 +144,22 @@ class AuthService:
if team is None:
raise InvalidTeamSelectionError(team_id)
if cfg.consent_required and not consent_accepted:
raise ConsentRequiredError
consent_version: int | None = None
consent_given_at: datetime | None = None
if cfg.consent_required and consent_accepted:
consent_version = cfg.consent_policy_version
consent_given_at = datetime.now(UTC)
user = await self._users.create(
email=email,
name_user=name_user,
password_hash=await hash_password(password),
team_id=team_id,
consent_version=consent_version,
consent_given_at=consent_given_at,
)
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to)

View File

@@ -3,80 +3,49 @@
Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`)
хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же
приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`).
Сама проверка содержимого (допустимые форматы, магические байты, реальный
размер) живёт в `services/images.py` — она общая с картинками фона видео
(`services/backgrounds.py`).
"""
import uuid
from collections.abc import Callable
from pathlib import Path
from fastapi import UploadFile
from services.images import (
ImageInvalidTypeError,
ImageTooLargeError,
read_and_validate_image,
)
# Лимит размера загружаемого аватара — 2 МБ.
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске.
_ALLOWED_CONTENT_TYPES: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
# доверять нельзя (легко подделать), реальный формат определяется по
# содержимому файла.
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
}
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
# самая длинная проверка, требует байты 8..11 включительно).
_MAGIC_HEAD_SIZE = 12
class AvatarTooLargeError(Exception):
class AvatarTooLargeError(ImageTooLargeError):
"""Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413)."""
class AvatarInvalidTypeError(Exception):
class AvatarInvalidTypeError(ImageInvalidTypeError):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]:
"""Прочитать содержимое файла аватара чанками и провалидировать тип/размер.
"""Прочитать содержимое файла аватара и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
мере чтения, затем магические байты содержимого — заявленный тип должен
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
например, исполняемый файл под видом `image/png`).
Возвращает `(содержимое, расширение)`. Ошибки общего валидатора
перезаворачиваются в «аватарные» — вызывающий код (`api/users.py`,
`api/admin.py`) отображает их в 413/415 и не должен знать про
`services/images.py`.
"""
declared_type = file.content_type
if declared_type not in _ALLOWED_CONTENT_TYPES:
raise AvatarInvalidTypeError(f"unsupported_content_type: {declared_type}")
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_AVATAR_SIZE_BYTES:
raise AvatarTooLargeError(f"file exceeds {MAX_AVATAR_SIZE_BYTES} bytes")
chunks.append(chunk)
content = b"".join(chunks)
magic_check = _MAGIC_CHECKS[declared_type]
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
raise AvatarInvalidTypeError("content_does_not_match_declared_content_type")
return content, _ALLOWED_CONTENT_TYPES[declared_type]
try:
return await read_and_validate_image(file, MAX_AVATAR_SIZE_BYTES)
except ImageTooLargeError as exc:
raise AvatarTooLargeError(str(exc)) from exc
except ImageInvalidTypeError as exc:
raise AvatarInvalidTypeError(str(exc)) from exc
def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str:

View File

@@ -0,0 +1,135 @@
"""Свои картинки пользователя для замены фона видео: лимит, файлы на диске, URL.
Как и аватары (`services/avatars.py`), картинки лежат **файлами на диске**
(`MEDIA_ROOT/backgrounds/{user_id}/{background_id}.{ext}`), а в БД — только
путь (`user_backgrounds.path`): требование оператора «чтобы не грузили БД».
Раздаёт их nginx напрямую (`location /media/`), в обход backend.
Картинку ужимает КЛИЕНТ (canvas → WebP, см. `frontend/src/lib/imageResize.ts`):
фон всё равно рендерится в браузере, и ставить Pillow на сервер ради одной
операции не нужно. Но валидация здесь остаётся полноценной — запрос может
прийти и мимо интерфейса.
Имя файла — id самой записи, а не порядковый номер: запись никогда не
перезаписывается (загрузка всегда создаёт новую), поэтому URL картинки
неизменен и его можно кэшировать браузером без cache-busting-параметра,
в отличие от аватара.
"""
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.user_background import UserBackground
from services.images import (
ImageInvalidTypeError,
ImageTooLargeError,
read_and_validate_image,
)
# Сколько своих картинок разрешено одному пользователю — прямое требование
# задачи («но не более 10»). Проверяется здесь, на сервере: ограничение только
# в интерфейсе обходится curl'ом.
MAX_BACKGROUNDS_PER_USER = 10
# Лимит размера загружаемого файла — 2 МБ, как у аватара. Клиент присылает
# ужатый WebP (обычно 100300 КБ), так что до лимита доходит только тот, кто
# шлёт запрос в обход интерфейса.
MAX_BACKGROUND_SIZE_BYTES = 2 * 1024 * 1024
class BackgroundTooLargeError(ImageTooLargeError):
"""Загружаемый файл превышает `MAX_BACKGROUND_SIZE_BYTES` (413)."""
class BackgroundInvalidTypeError(ImageInvalidTypeError):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
class BackgroundLimitReachedError(Exception):
"""У пользователя уже `MAX_BACKGROUNDS_PER_USER` картинок (409)."""
async def list_backgrounds(session: AsyncSession, user_id: uuid.UUID) -> list[UserBackground]:
"""Все картинки пользователя, свежие сверху."""
result = await session.execute(
select(UserBackground)
.where(UserBackground.user_id == user_id)
.order_by(UserBackground.created_at.desc(), UserBackground.id.desc())
)
return list(result.scalars().all())
async def add_background(
session: AsyncSession, media_root: Path, user_id: uuid.UUID, file: UploadFile
) -> UserBackground:
"""Провалидировать, сохранить на диск и завести запись о новой картинке.
Бросает `BackgroundTooLargeError`/`BackgroundInvalidTypeError`/
`BackgroundLimitReachedError`. Коммит — за вызывающим (роутером), как и в
остальных эндпоинтах профиля.
Строка пользователя блокируется (`FOR UPDATE`) на время проверки лимита:
без этого две одновременные загрузки (двойной клик по кнопке) обе
увидели бы «уже 9» и обе прошли бы — лимит, проверяемый на сервере,
обязан держаться и в этом случае.
"""
await session.execute(select(User.id).where(User.id == user_id).with_for_update())
count = await session.scalar(
select(func.count()).select_from(UserBackground).where(UserBackground.user_id == user_id)
)
if (count or 0) >= MAX_BACKGROUNDS_PER_USER:
raise BackgroundLimitReachedError(f"limit is {MAX_BACKGROUNDS_PER_USER}")
try:
content, ext = await read_and_validate_image(file, MAX_BACKGROUND_SIZE_BYTES)
except ImageTooLargeError as exc:
raise BackgroundTooLargeError(str(exc)) from exc
except ImageInvalidTypeError as exc:
raise BackgroundInvalidTypeError(str(exc)) from exc
# id генерируем здесь, а не полагаемся на `server_default`: он нужен ДО
# вставки, чтобы собрать имя файла на диске.
background_id = uuid.uuid4()
relative_path = f"backgrounds/{user_id}/{background_id}.{ext}"
background = UserBackground(id=background_id, user_id=user_id, path=relative_path)
session.add(background)
# Запись сначала, файл потом: если вставка не пройдёт (лимит, гонка,
# отвалившаяся БД), на диске не останется мусора.
await session.flush()
file_path = media_root / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(content)
return background
async def delete_background(
session: AsyncSession, media_root: Path, user_id: uuid.UUID, background_id: uuid.UUID
) -> bool:
"""Удалить картинку пользователя вместе с файлом. `False` — записи нет (404).
`user_id` в условии обязателен: без него владелец записи не проверялся бы
и любой аутентифицированный пользователь мог бы удалить чужую картинку,
зная её id.
"""
background = await session.scalar(
select(UserBackground).where(
UserBackground.id == background_id, UserBackground.user_id == user_id
)
)
if background is None:
return False
file_path = media_root / background.path
file_path.unlink(missing_ok=True)
await session.delete(background)
return True
def background_url(path: str) -> str:
"""Публичный URL картинки фона (раздаётся nginx из тома `media`)."""
return f"/media/{path}"

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError
return conference
async def hand_queue_enabled(self) -> bool:
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
осознанная «застылость» на время жизни соединения, что и у
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
администратором применяется со следующего подключения."""
cfg = await InstanceSettingsService(self._session).get()
return cfg.hand_queue.enabled
async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -53,17 +53,21 @@ def build_join(
identity: str,
name: str,
chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
virtual_background_enabled: bool,
avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
стороной значения `instance_settings`: читаются здесь параметрами, а не
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
`chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`/
`virtual_background_enabled`
— снятые вызывающей стороной значения `instance_settings`: читаются здесь
параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
на каждый join.
`avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
@@ -91,6 +95,8 @@ def build_join(
room_name=conference.slug,
conference_id=conference.id,
chat_enabled=chat_enabled,
hand_queue_enabled=hand_queue_enabled,
publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles,
virtual_background_enabled=virtual_background_enabled,
)

View File

@@ -155,8 +155,10 @@ class ConferenceService:
identity=str(owner_id),
name=owner_name,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
)
@@ -251,8 +253,10 @@ class ConferenceService:
identity=str(user.id),
name=user.name_user,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
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,
)
@@ -275,8 +279,10 @@ class ConferenceService:
identity=f"guest:{guest.id}",
name=data.display_name,
chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
)
async def mute_participant(

View File

@@ -0,0 +1,35 @@
"""Дефолтный текст регламента обработки персональных данных (ключ `instance_settings.consent_policy`).
Вынесен из `services/instance_settings.py` в отдельный модуль ради `E501`:
это прозаический шаблон документа, а не код, оборачивать его строки ради
лимита длины строки бессмысленно — так же, как `email_templates.py`
(см. `[tool.ruff.lint.per-file-ignores]` в `pyproject.toml`).
"""
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
1. Оператор персональных данных
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
2. Правовое основание обработки
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
3. Состав и цели обработки
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
4. Срок обработки и хранения
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
5. Действия с персональными данными
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
6. Права субъекта персональных данных
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
7. Согласие
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
скобках, без указания конкретной организации — администратор обязан
заменить их под свою организацию перед вводом в эксплуатацию."""

View File

@@ -0,0 +1,79 @@
"""Общая валидация загружаемых картинок: тип по содержимому и реальный размер.
Выделено из `services/avatars.py`, когда те же правила понадобились фонам
видео (`services/backgrounds.py`): списки допустимых форматов и сигнатур
должны быть в одном месте — разъехавшись, они дали бы дыру ровно в том
месте, ради которого проверка и написана.
Правила намеренно не смягчаются для «уже проверенных на клиенте» файлов:
браузер жмёт картинку фона перед отправкой (canvas → WebP), но запрос к API
может прийти и мимо интерфейса — клиенту верить нельзя.
"""
from collections.abc import Callable
from fastapi import UploadFile
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске.
ALLOWED_IMAGE_CONTENT_TYPES: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
# доверять нельзя (легко подделать), реальный формат определяется по
# содержимому файла.
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
}
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
# самая длинная проверка, требует байты 8..11 включительно).
_MAGIC_HEAD_SIZE = 12
class ImageTooLargeError(Exception):
"""Загружаемый файл превышает переданный лимит размера (413)."""
class ImageInvalidTypeError(Exception):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_image(file: UploadFile, max_size_bytes: int) -> tuple[bytes, str]:
"""Прочитать файл чанками и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
мере чтения, затем магические байты содержимого — заявленный тип должен
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
например, исполняемый файл под видом `image/png`).
"""
declared_type = file.content_type
if declared_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise ImageInvalidTypeError(f"unsupported_content_type: {declared_type}")
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > max_size_bytes:
raise ImageTooLargeError(f"file exceeds {max_size_bytes} bytes")
chunks.append(chunk)
content = b"".join(chunks)
magic_check = _MAGIC_CHECKS[declared_type]
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
raise ImageInvalidTypeError("content_does_not_match_declared_content_type")
return content, ALLOWED_IMAGE_CONTENT_TYPES[declared_type]

View File

@@ -1,7 +1,7 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`,
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
@@ -28,6 +28,7 @@ from core.config import Settings
from core.plugins.config import (
AiLevel,
ChatConfig,
HandQueueConfig,
InstanceConfig,
MediaLimitsConfig,
PluginsConfig,
@@ -41,10 +42,12 @@ from core.plugins.config import (
from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
from services.consent_policy_text import DEFAULT_CONSENT_POLICY_TEXT
_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
@@ -52,6 +55,9 @@ _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
_KEY_DEVICE_CHECK = "device_check"
_KEY_VIRTUAL_BACKGROUND = "virtual_background"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT,
@@ -64,6 +70,26 @@ BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`."""
MANAGED_KEYS: tuple[str, ...] = (
_KEY_TRANSCRIBER,
_KEY_SUMMARIZER,
_KEY_CHAT,
_KEY_HAND_QUEUE,
_KEY_AI_LEVEL,
_KEY_SUMMARY_RECIPIENTS,
_KEY_DISPLAY_TIMEZONE,
_KEY_REGISTRATION_TEAM_CHOICE,
_KEY_REGISTRATION_EMAIL_DOMAIN,
_KEY_CONTACT_EMAIL,
_KEY_MEDIA_LIMITS,
_KEY_CONSENT_POLICY,
_KEY_DEVICE_CHECK,
_KEY_VIRTUAL_BACKGROUND,
)
"""Все ключи, которыми управляет `InstanceSettingsService` — единый источник истины
для тестовой изоляции от состояния `instance_settings` в общей dev-БД
(`tests/conftest.py::clean_instance_settings`)."""
_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
@@ -76,6 +102,17 @@ _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}
_DEFAULT_DEVICE_CHECK_VALUE = {"enabled": False}
_DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
"""Замена фона видео (сессия 35). Дефолт — выключено: фича постоянно считает
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""
_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
"enabled": False,
"text": DEFAULT_CONSENT_POLICY_TEXT,
"version": 1,
}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -100,6 +137,7 @@ class SettingsUpdateIn(BaseModel):
"""
chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None
ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None
@@ -111,6 +149,10 @@ class SettingsUpdateIn(BaseModel):
contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
consent_required: bool | None = None
consent_policy_text: str | None = None
device_check_enabled: bool | None = None
virtual_background_enabled: bool | None = None
class BootstrapOverrides(BaseModel):
@@ -154,6 +196,7 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
@@ -161,6 +204,9 @@ def build_bootstrap_defaults(
_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),
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
_KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE),
_KEY_VIRTUAL_BACKGROUND: dict(_DEFAULT_VIRTUAL_BACKGROUND_VALUE),
}
if overrides is None:
return defaults
@@ -197,6 +243,11 @@ class InvalidEmailDomainError(ValueError):
"""
class InvalidConsentPolicyError(ValueError):
"""Попытка включить обязательное согласие при пустом тексте регламента
(`consent_required=True` без непустого `consent_policy_text`)."""
class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса.
@@ -286,6 +337,18 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.device_check_enabled is not None:
cfg.device_check_enabled = patch.device_check_enabled
await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled})
if patch.virtual_background_enabled is not None:
cfg.virtual_background_enabled = patch.virtual_background_enabled
await self._set(_KEY_VIRTUAL_BACKGROUND, {"enabled": patch.virtual_background_enabled})
if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice
await self._set(
@@ -363,6 +426,36 @@ class InstanceSettingsService:
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"))
if patch.consent_required is not None or patch.consent_policy_text is not None:
consent_required = (
patch.consent_required
if patch.consent_required is not None
else cfg.consent_required
)
consent_text = (
patch.consent_policy_text.strip()
if patch.consent_policy_text is not None
else cfg.consent_policy_text
)
if consent_required and not consent_text:
raise InvalidConsentPolicyError(
"нельзя включить обязательное согласие с пустым текстом регламента"
)
# Версия — счётчик редакций текста, а не хеш/дата: администратору
# проще сослаться на «редакцию №3», чем на хеш, а инкремент (в
# отличие от даты) однозначно фиксирует факт правки даже при
# повторном сохранении одного и того же текста в одну секунду.
consent_version = cfg.consent_policy_version
if consent_text != cfg.consent_policy_text:
consent_version += 1
cfg.consent_required = consent_required
cfg.consent_policy_text = consent_text
cfg.consent_policy_version = consent_version
await self._set(
_KEY_CONSENT_POLICY,
{"enabled": consent_required, "text": consent_text, "version": consent_version},
)
await self._session.commit()
return cfg
@@ -399,6 +492,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber,
summarizer=plugins.summarizer,
chat=plugins.chat,
hand_queue=plugins.hand_queue,
)
else:
cfg = _build_config(rows)
@@ -492,6 +586,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all"
@@ -515,4 +610,19 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"enabled", False
),
consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"text", DEFAULT_CONSENT_POLICY_TEXT
),
consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"version", 1
),
device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get(
"enabled", False
),
virtual_background_enabled=rows.get(
_KEY_VIRTUAL_BACKGROUND, _DEFAULT_VIRTUAL_BACKGROUND_VALUE
).get("enabled", False),
)

View File

@@ -21,10 +21,11 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from starlette.types import Message, Scope
from core.db import engine, get_session
from core.db import engine, get_metrics_session, get_session
from core.redis import redis_client
from main import create_app
from models.instance_setting import InstanceSetting
from services.instance_settings import MANAGED_KEYS
@pytest_asyncio.fixture(autouse=True)
@@ -88,6 +89,37 @@ async def _load_committed_instance_settings() -> dict[str, Any]:
return {key: value for key, value in result.all()}
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от текущего состояния управляемых ключей `instance_settings` в
общей dev-БД (тоггл чата, поднятия руки и т.п. — живые настройки разработчика, а не
тестовые данные).
Удаляет строки `MANAGED_KEYS` внутри savepoint-транзакции теста (`db_session`) —
последующее чтение конфигурации (`InstanceSettingsService.get`/`load_effective_config`)
падает на дефолты pydantic-моделей (например, `ChatConfig.enabled == True`), одинаковые
независимо от того, что реально сохранено в dev-БД в момент прогона. Savepoint
откатывается в `db_connection` по завершении теста — восстанавливать исходные строки
вручную не нужно, в отличие от `_preserve_instance_settings` (та фикстура страхует от
записи МИМО savepoint, эта — от чтения ИЗ него состояния, унаследованного от dev-БД).
НЕ autouse и намеренно: `DELETE` внутри savepoint держит Postgres-блокировку на
строке до конца ВНЕШНЕЙ транзакции теста (`db_connection`, откатывается только в
teardown) — savepoint её не освобождает раньше срока. Тесты, которые параллельно
внутри СЕБЯ же пишут в те же ключи через ОТДЕЛЬНОЕ реальное подключение
(`async_session_maker`/`engine.connect()` — см. `test_transcription_disabled_setting_
stops_run_pipeline`, `clean_bootstrap_managed_keys`), заблокировались бы сами на себе,
если бы эта фикстура применялась к ним автоматически (наступили при первой попытке
сделать её autouse — само-дедлок, тест висел до ручного убийства процесса). Поэтому
запрашивать явно, только в тестах, где именно ОНА обеспечивает изоляцию (WS чата/
очереди рук и т.п.), а не там, где тест сам управляет состоянием через реальные
коммиты.
"""
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(MANAGED_KEYS)))
await db_session.commit()
yield
@pytest_asyncio.fixture
async def db_connection() -> AsyncGenerator[AsyncConnection, None]:
async with engine.connect() as connection:
@@ -113,13 +145,21 @@ async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSess
@pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию."""
"""Экземпляр FastAPI-приложения с `get_session`/`get_metrics_session`,
подменёнными на тестовую (savepoint) сессию.
`get_metrics_session` (сессия 37, `core/db.py`) в проде — отдельный от
основного пула движок, но в тестах должен указывать на ТУ ЖЕ savepoint-
сессию, что и `get_session` — иначе `/metrics` не видел бы данные теста,
ещё не закоммиченные за пределы savepoint (см. `test_metrics_api.py`).
"""
application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session
application.dependency_overrides[get_session] = _override_get_session
application.dependency_overrides[get_metrics_session] = _override_get_session
yield application

View File

@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
response = await client.put(
"/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"},
json={
"chat_enabled": False,
"hand_queue_enabled": False,
"display_timezone": "Asia/Yekaterinburg",
},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["chat_enabled"] is False
assert body["hand_queue_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False

View File

@@ -40,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None:
`conftest.py`).
"""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False)
SettingsUpdateIn(
registration_team_choice=False,
registration_email_domain_enabled=False,
consent_required=False,
)
)
await db_session.commit()
@@ -495,6 +499,98 @@ async def test_register_no_reply_to_when_contact_email_disabled(
assert email_backend.reply_to[-1] is None
async def test_registration_options_returns_consent_fields(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`consent_text`/`consent_version` отдаются ВСЕГДА (нужны странице регламента),
`consent_required` — по факту настройки инстанса."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_policy_text="Текст регламента для теста")
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
body = response.json()
assert body["consent_required"] is False
assert body["consent_text"] == "Текст регламента для теста"
assert isinstance(body["consent_version"], int)
async def test_register_without_consent_when_required_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Сервер отказывает в регистрации без галочки, даже если фронт её не прислал —
второй эшелон проверки (тот же принцип, что `hand_queue_disabled` в 0.0.28)."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "no-consent@example.com",
"name_user": "No Consent",
"password": "supersecret1",
},
)
assert response.status_code == 400
assert response.json()["detail"] == "consent_required"
result = await db_session.execute(select(User).where(User.email == "no-consent@example.com"))
assert result.scalar_one_or_none() is None
async def test_register_with_consent_when_required_writes_version_and_date(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Принятое согласие пишется в БД вместе с редакцией регламента и датой."""
cfg = await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента для приёмки")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "with-consent@example.com",
"name_user": "With Consent",
"password": "supersecret1",
"consent_accepted": True,
},
)
assert response.status_code == 201, response.text
result = await db_session.execute(select(User).where(User.email == "with-consent@example.com"))
created = result.scalar_one()
assert created.consent_version == cfg.consent_policy_version
assert created.consent_given_at is not None
async def test_register_without_consent_when_module_disabled_succeeds_and_leaves_it_null(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Модуль выключен (дефолт `_reset_registration_gating`) — регистрация не требует
галочки, `consent_version`/`consent_given_at` остаются `NULL`."""
response = await client.post(
"/api/v1/auth/register",
json={
"email": "consent-disabled@example.com",
"name_user": "Consent Disabled",
"password": "supersecret1",
},
)
assert response.status_code == 201, response.text
result = await db_session.execute(
select(User).where(User.email == "consent-disabled@example.com")
)
created = result.scalar_one()
assert created.consent_version is None
assert created.consent_given_at is None
async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:

View File

@@ -7,13 +7,14 @@ websocket-тестов поверх нашей savepoint-сессии БД (см
"""
import uuid
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from datetime import UTC, datetime
from typing import Any
import httpx
import jwt
import pytest
import pytest_asyncio
from pydantic import ValidationError
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,16 @@ from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — изолирует тесты от состояния `chat`/`hand_queue`
(и остальных управляемых ключей) в общей dev-БД (см. `tests.conftest.clean_instance_settings`,
почему не сделана глобально autouse). Безопасно именно здесь: ни один тест файла не
открывает отдельного подключения к `instance_settings` — только `db_session`/`client`.
"""
yield
# --- Хелперы ---------------------------------------------------------------
@@ -237,6 +248,42 @@ async def test_no_duplicate_when_message_already_in_history(
assert received["message"]["text"] == "genuinely new"
# --- Удержание соединения с БД ------------------------------------------------
async def test_handshake_releases_db_connection(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Regression: после хендшейка WS не держит открытую транзакцию БД.
Обработчик получает `AsyncSession` на ВСЁ время жизни соединения, а
SELECT'ы хендшейка (тоггл чата, конференция, тоггл рук, история)
открывают транзакцию. Без явного `commit` она висела бы, пока участник
сидит в комнате: одно занятое соединение из пула на каждого человека
в конференции. На нагрузочном тесте 07.08.2026 это выгребло пул
(`db_pool_size + db_max_overflow` = 20 на воркер, 40 на инстанс) при
сорока участниках — и вход в систему начал отдавать 500 всем
остальным. Проверяем именно отсутствие открытой транзакции, а не
состояние пула: тестовая сессия привязана к своему соединению
(см. докстринг `tests/conftest.py`) и пул не задействует.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, _user_token(conference, user))
assert not db_session.in_transaction()
# Запись сообщения открывает транзакцию заново — и тоже обязана её
# закрыть, иначе первый же чат вернул бы прежнее поведение.
await ws.send_json({"type": "message", "text": "проверка"})
echo = await ws.receive_json()
assert echo["type"] == "message"
assert not db_session.in_transaction()
# --- Auth: коды закрытия ----------------------------------------------------

View File

@@ -21,6 +21,7 @@ from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -117,6 +118,12 @@ async def test_create_instant_conference_returns_active_with_join(
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
# Замена фона — отключаемый модуль (сессия 35); значение нужно клиенту ДО
# первого рендера комнаты, поэтому едет в join. Здесь проверяется только
# НАЛИЧИЕ поля: тумблер живёт в общей dev-БД, и сравнение с дефолтом
# ломалось бы от того, что разработчик включил модуль руками (см.
# `test_join_reports_virtual_background_toggle` — там значение задаётся явно).
assert isinstance(body["join"]["virtual_background_enabled"], bool)
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -718,6 +725,30 @@ async def test_join_closed_conference_correct_password_returns_200(
assert response.json()["room_name"] == conference.slug
@pytest.mark.parametrize("enabled", [True, False])
async def test_join_reports_virtual_background_toggle(
client: httpx.AsyncClient, db_session: AsyncSession, enabled: bool
) -> None:
"""Тумблер модуля «замена фона» доезжает до участника в ответе join.
Значение задаётся ЯВНО, а не берётся из общей dev-БД: клиенту оно нужно до
первого рендера комнаты, и подмена тумблера обязана менять ответ join, а не
только `/admin/settings` (тот доступен лишь администратору).
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(virtual_background_enabled=enabled)
)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", json={}, headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
assert response.json()["virtual_background_enabled"] is enabled
# --- Аватар в метаданных LiveKit-токена -----------------------------------------

View File

@@ -5,22 +5,32 @@
"""
import uuid
from collections.abc import Callable
from collections.abc import AsyncGenerator, Callable
from typing import Any
import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import hash_password
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
@pytest_asyncio.fixture(autouse=True)
async def _isolated_instance_settings(clean_instance_settings: None) -> AsyncGenerator[None, None]:
"""Autouse только в этом модуле — см. `tests.test_chat_ws._isolated_instance_settings`
и `tests.conftest.clean_instance_settings` (почему не глобальный autouse)."""
yield
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
@@ -270,3 +280,66 @@ async def test_organizer_joining_late_sees_already_raised_hands(
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)]
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
async def test_hand_queue_disabled_rejects_raise_and_lower(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _user_token(conference, alice))
await ws.send_json({"type": "raise_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
await ws.send_json({"type": "lower_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
async def test_join_out_reflects_hand_queue_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session, name="Toggle Tester")
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is False
async def test_guest_join_out_reflects_hand_queue_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["hand_queue_enabled"] is False

View File

@@ -12,15 +12,12 @@
видеть реально закоммиченную строку (см. docstring `test_pipeline.py`),
поэтому там запись делается через отдельное подключение к `core.db.engine`.
Тесты, которым нужен «чистый стол» по управляемым ключам `instance_settings`
(бутстрап дефолтов, дефолтные значения после патча), используют фикстуру
`clean_instance_settings` — она сохраняет текущие строки этих ключей в
рамках транзакции теста и восстанавливает их после (не `TRUNCATE`): в общей
dev-БД эти строки могут быть легитимными данными разработчика, тест не
должен от них зависеть, но и не должен их безвозвратно стирать. Тест с
`run_pipeline_async` не может использовать эту фикстуру (пишет через
отдельное подключение) — там то же сохранение/восстановление сделано вручную
через реальный коннекшн.
«Чистый стол» по управляемым ключам `instance_settings` обеспечивает общая (НЕ
autouse — см. её докстринг про само-дедлок с тестами на реальных подключениях)
фикстура `tests.conftest.clean_instance_settings` — тесты, которым нужен чистый
стол, запрашивают её явно параметром. Тест с `run_pipeline_async` пишет через
отдельное подключение (мимо savepoint) и её не запрашивает — там сохранение/
восстановление сделано вручную через реальный коннекшн, см. его докстринг.
"""
import uuid
@@ -48,6 +45,7 @@ from services.instance_settings import (
BootstrapOverrides,
InstanceSettingsService,
InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError,
InvalidEmailDomainError,
InvalidTimezoneError,
@@ -60,20 +58,6 @@ from workers.tasks.pipeline import run_pipeline_async
PLUGINS_YAML = "../config/plugins.yaml"
NOW = datetime.now(UTC)
# Все ключи, которыми управляет `InstanceSettingsService` (см. `_KEY_*` там же).
_MANAGED_KEYS = (
"transcriber",
"summarizer",
"chat",
"ai_level",
"summary_recipients",
"display_timezone",
"registration_team_choice",
"registration_email_domain",
"contact_email",
"media_limits",
)
class _FakeTask:
"""Минимальная заглушка bound-задачи Celery (см. `test_pipeline.py`)."""
@@ -82,32 +66,6 @@ class _FakeTask:
self.retry = MagicMock()
@pytest_asyncio.fixture
async def clean_instance_settings(db_session: AsyncSession) -> AsyncGenerator[None, None]:
"""Изолировать тест от уже существующих строк управляемых ключей `instance_settings`.
Сохраняет текущие значения (если есть) в рамках `db_session` (savepoint,
никогда не коммитится в реальную БД — см. `conftest.py`), удаляет их,
отдаёт управление тесту, затем восстанавливает исходные значения —
точечно, только эти ключи, не `TRUNCATE`.
"""
result = await db_session.execute(
select(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
saved: dict[str, Any] = {row.key: row.value for row in result.scalars().all()}
await db_session.execute(delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS)))
await db_session.commit()
try:
yield
finally:
await db_session.execute(
delete(InstanceSetting).where(InstanceSetting.key.in_(_MANAGED_KEYS))
)
for key, value in saved.items():
db_session.add(InstanceSetting(key=key, value=value))
await db_session.commit()
async def test_ensure_bootstrapped_imports_yaml_defaults(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
@@ -121,6 +79,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"transcriber",
"summarizer",
"chat",
"hand_queue",
"ai_level",
"summary_recipients",
"display_timezone",
@@ -128,10 +87,16 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"registration_email_domain",
"contact_email",
"media_limits",
"consent_policy",
"device_check",
"virtual_background",
}
cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu"
assert cfg.ai_level == "min"
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
assert cfg.hand_queue.enabled is True
assert cfg.summary_recipients == "all"
assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False
@@ -144,6 +109,19 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
# Согласие на обработку персональных данных выключено по умолчанию
# (дефолт сохраняет поведение существующих инсталляций), но дефолтный
# текст-шаблон уже на месте — публичная страница регламента осмысленна
# даже при выключенном модуле.
assert cfg.consent_required is False
assert cfg.consent_policy_text != ""
assert cfg.consent_policy_version == 1
# Проверка устройств на входе (сессия 33) — выключена по умолчанию,
# существующие инсталляции не должны молча начать спрашивать доступ.
assert cfg.device_check_enabled is False
# Замена фона видео (сессия 35) — выключена по умолчанию: фича постоянно
# считает сегментацию на клиенте, включать её обновлением нельзя.
assert cfg.virtual_background_enabled is False
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -162,6 +140,54 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
assert cfg.display_timezone == "Asia/Yekaterinburg"
async def test_update_hand_queue_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
assert cfg.hand_queue.enabled is False
cfg = await service.get()
assert cfg.hand_queue.enabled is False
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
assert cfg.hand_queue.enabled is True
async def test_update_device_check_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(device_check_enabled=True))
assert cfg.device_check_enabled is True
cfg = await service.get()
assert cfg.device_check_enabled is True
cfg = await service.update(SettingsUpdateIn(device_check_enabled=False))
assert cfg.device_check_enabled is False
async def test_update_virtual_background_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=True))
assert cfg.virtual_background_enabled is True
cfg = await service.get()
assert cfg.virtual_background_enabled is True
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=False))
assert cfg.virtual_background_enabled is False
@pytest.mark.parametrize(
("preset", "chat_enabled", "ai_enabled", "ai_level"),
[
@@ -330,6 +356,61 @@ async def test_registration_team_choice_toggle(
assert reloaded.registration_team_choice is True
async def test_consent_policy_toggle_without_text_change_keeps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение флага без правки текста не увеличивает версию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
assert baseline.consent_required is False
cfg = await service.update(SettingsUpdateIn(consent_required=True))
assert cfg.consent_required is True
assert cfg.consent_policy_version == baseline.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_required is True
assert reloaded.consent_policy_version == baseline.consent_policy_version
async def test_consent_policy_text_change_bumps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg.consent_policy_text == "Новый текст регламента"
assert cfg.consent_policy_version == baseline.consent_policy_version + 1
# Повторное сохранение ТОГО ЖЕ текста версию больше не двигает.
cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg2.consent_policy_version == cfg.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_policy_version == cfg.consent_policy_version
async def test_consent_policy_enable_with_empty_text_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Нельзя включить обязательное согласие, если текст регламента пуст."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidConsentPolicyError):
await service.update(
SettingsUpdateIn(consent_required=True, consent_policy_text=" ")
)
cfg = await service.get()
assert cfg.consent_required is False
async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:

View File

@@ -59,6 +59,12 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families
assert "vidconf_host_info" in families
assert "vidconf_db_up" in families
assert "vidconf_db_pool_size" in families
assert "vidconf_db_pool_max_overflow" in families
assert "vidconf_db_pool_checked_out" in families
assert "vidconf_redis_pool_in_use" in families
assert "vidconf_redis_pool_max_connections" in families
async def test_metrics_host_info_gauge_reflects_settings(
@@ -129,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
assert after == before + 1
async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None:
"""Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1."""
response = await client.get("/metrics")
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 1
async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500."""
async def _fail() -> bool:
return False
monkeypatch.setattr(metrics_module, "check_db_up", _fail)
response = await client.get("/metrics")
assert response.status_code == 200
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 0
async def test_metrics_db_pool_gauges_reflect_settings_not_usage(
client: httpx.AsyncClient,
) -> None:
"""`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость."""
settings = get_settings()
response = await client.get("/metrics")
samples_size = _samples(response.text, "vidconf_db_pool_size")
samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow")
size = _sample_value(samples_size, suffix="vidconf_db_pool_size")
max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow")
assert size == settings.db_pool_size
assert max_overflow == settings.db_max_overflow
async def test_metrics_endpoint_survives_pipeline_gauge_failure(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`.
Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе
со всем остальным при исчерпанном пуле): `count_by_pipeline_status`
поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие
от основного пула) при этом всё равно приходят в ответе.
"""
async def _raise(*args: object, **kwargs: object) -> dict[str, int]:
raise TimeoutError("основной пул занят (симуляция теста)")
monkeypatch.setattr(
"repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status",
_raise,
)
response = await client.get("/metrics")
assert response.status_code == 200
db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert db_up == 1
async def test_metrics_celery_queue_depth_gauge(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:

View File

@@ -0,0 +1,59 @@
"""Тесты публичного эндпоинта настроек клиента (`/api/v1/public/settings`, сессия 33)."""
import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
@pytest_asyncio.fixture(autouse=True)
async def _reset_public_flags(db_session: AsyncSession) -> None:
"""Сбросить публичные тогглы перед каждым тестом — общая dev-БД не изолирована
от ручных правок администратора (та же дисциплина, что и `_reset_registration_gating`
в `test_auth.py`); `db_session` не коммитится в реальную БД, см. `conftest.py`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(device_check_enabled=False, virtual_background_enabled=False)
)
async def test_public_settings_disabled_by_default(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json() == {"device_check_enabled": False, "virtual_background_enabled": False}
async def test_public_settings_reflects_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
await InstanceSettingsService(db_session).update(SettingsUpdateIn(device_check_enabled=True))
await db_session.commit()
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json()["device_check_enabled"] is True
async def test_public_settings_reflects_virtual_background_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Замена фона нужна и на публичном превью входа (`JoinPage`) — до аутентификации."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(virtual_background_enabled=True)
)
await db_session.commit()
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json()["virtual_background_enabled"] is True
async def test_public_settings_requires_no_auth(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Эндпоинт публичный — работает без заголовка `Authorization`, как и обязан
(обе страницы, которым он нужен, доступны до входа в систему)."""
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text

View File

@@ -1,4 +1,4 @@
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар."""
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар, картинки фона."""
import uuid
from collections.abc import Generator
@@ -12,6 +12,7 @@ from core.config import get_settings
from core.security import create_access_token, hash_password
from models.team import Team
from models.user import User
from services.backgrounds import MAX_BACKGROUNDS_PER_USER
# Минимальные валидные по магическим байтам содержимые (без полноценного
# декодирования — `services/avatars.py` проверяет только сигнатуру/размер).
@@ -393,3 +394,150 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
ids = {item["id"] for item in response.json()}
assert str(match.id) in ids
assert str(requester.id) not in ids
# --- Свои картинки фона (`/users/me/backgrounds`) -----------------
async def _upload_background(
client: httpx.AsyncClient, user: User, *, name: str = "bg.webp"
) -> httpx.Response:
return await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": (name, _WEBP_BYTES, "image/webp")},
)
async def test_backgrounds_list_is_empty_by_default_and_reports_limit(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["items"] == []
assert body["limit"] == MAX_BACKGROUNDS_PER_USER
async def test_upload_background_saves_file_and_returns_url(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await _upload_background(client, user)
assert response.status_code == 201, response.text
items = response.json()["items"]
assert len(items) == 1
url = items[0]["url"]
assert url == f"/media/backgrounds/{user.id}/{items[0]['id']}.webp"
# Файл лежит на диске, в БД только путь — см. `services/backgrounds.py`.
assert (media_root / url.removeprefix("/media/")).read_bytes() == _WEBP_BYTES
async def test_upload_background_spoofed_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`Content-Type: image/webp`, но байты — JPEG: клиенту не верим и здесь."""
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", _JPEG_BYTES, "image/webp")},
)
assert response.status_code == 415
assert response.json()["detail"] == "background_invalid_type"
async def test_upload_background_too_large_returns_413(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
oversized = _WEBP_BYTES + b"\x00" * (2 * 1024 * 1024)
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", oversized, "image/webp")},
)
assert response.status_code == 413
assert response.json()["detail"] == "background_too_large"
async def test_upload_background_over_limit_returns_409_and_keeps_ten(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Лимит держится на СЕРВЕРЕ: интерфейс можно обойти прямым запросом."""
user = await _make_user(db_session)
await db_session.commit()
for _ in range(MAX_BACKGROUNDS_PER_USER):
assert (await _upload_background(client, user)).status_code == 201
response = await _upload_background(client, user)
assert response.status_code == 409
assert response.json()["detail"] == "background_limit_reached"
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert len(listing.json()["items"]) == MAX_BACKGROUNDS_PER_USER
async def test_delete_background_removes_file_and_row(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, user)).json()["items"][0]
file_path = media_root / created["url"].removeprefix("/media/")
assert file_path.exists()
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(user)
)
assert response.status_code == 204
assert not file_path.exists()
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert listing.json()["items"] == []
async def test_delete_other_users_background_returns_404(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Знание id чужой картинки не даёт её удалить — владелец в условии запроса."""
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, owner)).json()["items"][0]
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(stranger)
)
assert response.status_code == 404
assert (media_root / created["url"].removeprefix("/media/")).exists()
async def test_backgrounds_list_does_not_leak_other_users_images(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
await _upload_background(client, owner)
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(stranger))
assert response.json()["items"] == []
async def test_backgrounds_require_authentication(
media_root: Path, client: httpx.AsyncClient
) -> None:
assert (await client.get("/api/v1/users/me/backgrounds")).status_code == 401
assert (
await client.delete(f"/api/v1/users/me/backgrounds/{uuid.uuid4()}")
).status_code == 401

View File

@@ -33,3 +33,6 @@ summarizer:
chat:
enabled: true
hand_queue:
enabled: true

View File

@@ -44,6 +44,17 @@ pkey=/etc/coturn/certs/key.pem
log-file=stdout
simple-log
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
# печатает по сессии: create/delete allocation, create permission, refresh —
# достаточно, чтобы дальше проверять относительно скромный вывод.
verbose
# Внешний IP сервера — обязателен для клиентов вне docker-сети
# (network_mode: host здесь не даёт coturn определить публичный IP
# автоматически). Для локальной разработки (без внешних участников)

View File

@@ -69,7 +69,12 @@ services:
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
# Собран из POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB с жёстким хостом
# `postgres` (сессия 37) — так же, как REDIS_URL ниже, а не читается из
# .env.DATABASE_URL напрямую: тот в корневом .env указывает на `localhost`
# (для запуска backend на хосте вне контейнера, см. CONTEXT §2.1.0), а
# `localhost` внутри контейнера — это сам контейнер, не соседний postgres.
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?LIVEKIT_API_KEY не задан в .env}
@@ -89,7 +94,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.22}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -143,7 +148,7 @@ services:
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
PYTHONPATH: /app
@@ -240,7 +245,7 @@ services:
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}
@@ -296,7 +301,7 @@ services:
env_file:
- ../.env
environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://vidconf:vidconf@postgres:5432/vidconf}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-vidconf}:${POSTGRES_PASSWORD:-vidconf}@postgres:5432/${POSTGRES_DB:-vidconf}
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env}@redis:6379/0
PLUGINS_CONFIG_PATH: ${PLUGINS_CONFIG_PATH:-config/plugins.yaml}
RECORDINGS_DIR: ${RECORDINGS_DIR:-/recordings}

View File

@@ -61,6 +61,89 @@ groups:
# см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
# Состояние БД и пулов соединений (сессия 33, разбор инцидента 07.08 —
# `.forcc/session-results/32-loadtest-07-08-debug.md`). `/api/health`
# отдавал 200 с `db: false` во время отказа — Prometheus его не скрейпит и
# не умеет разобрать JSON-тело, поэтому оба сигнала строятся на метриках
# `backend/api/metrics.py`, которые читаются вне основного пула.
- name: vidconf-db
rules:
# `vidconf_db_up` — отдельное соединение вне основного пула
# (`core/db.py::check_db_up`), поэтому 0 означает именно «БД не
# отвечает», а не «пул занят» (для второго см. DbConnectionPoolNearExhaustion
# ниже — раздельные метрики нарочно, см. «Главное требование» промпта
# сессии 33). `for: 30s` — два цикла скрейпа (`scrape_interval: 15s`),
# чтобы не среагировать на одиночный неудачный `connect()` (сеть/GC-пауза),
# но не тянуть с сигналом дольше: это самый критичный алерт в проекте.
- alert: DatabaseUnavailable
expr: vidconf_db_up == 0
for: 30s
labels:
severity: critical
annotations:
summary: "БД недоступна"
description: >-
vidconf_db_up == 0 дольше 30 секунд — backend не может открыть
отдельное (вне основного пула) соединение с Postgres. НЕ значит
автоматически «нужен рестарт контейнера» — по решению оператора
от 09.08 healthcheck backend'а остаётся мягким (не хардфейлится
на недоступной БД — рестарт-петля в разгар инцидента оборвала бы
WS у всех, кто в конференциях), это сигнал оператору, не
автолечение. Смотреть
`docker compose ps postgres`, `docker compose logs postgres`,
`pg_isready`.
# Раннее предупреждение — тот самый сигнал, которого не хватило
# 07.08: пул заполнялся постепенно (idle in transaction 3→8→16→26→35→
# 39→40 участников комнаты, см. session 32), а `up{job="backend"}`
# ничего не показывал, потому что backend отвечал исправно вплоть до
# самого потолка. Порог 80% — предложение из промпта сессии 33,
# `for: 1m` — фильтр от секундных всплесков (короткий пик параллельных
# запросов рассасывается за секунды, устойчивый рост участников
# комнаты — нет). На нагрузочном тесте 07.08 от пересечения 80% до
# исчерпания пула прошло по грубой оценке меньше двух минут — порог
# НЕ даёт большого запаса и это осознанный компромисс, а не идеал:
# цель — успеть до 500-х у пользователей, а не за много минут
# заранее. Перепроверить оба числа на следующем нагрузочном тесте
# (см. .forcc/session-results/33-db-health-alert.md) и подстроить,
# если реальный запас окажется у́же ожидаемого.
- alert: DbConnectionPoolNearExhaustion
expr: >-
(vidconf_db_pool_checked_out
/ (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100 > 80
for: 1m
labels:
severity: warning
annotations:
summary: "Основной пул соединений с БД близок к исчерпанию"
description: >-
Занято {{ $value | printf "%.0f" }}% основного пула БД дольше
минуты (порог 80%). Частая причина в этом проекте — долгоживущие
WS-подключения комнат (`api/chat.py`) держат соединение на
каждого сидящего в конференции; смотреть
`vidconf_db_pool_checked_out` и число открытых WS чата в логах,
не только текущую HTTP-нагрузку.
# Тот же класс отказа, что у пула БД (см. выше), только пул Redis —
# закрыт в 0.0.31 (`451c18e`) заданием явного max_connections, но без
# метрики занятости прошлый потолок нашёлся только руками на
# нагрузочном тесте. Бонус к задаче сессии 33 («потолки в этом
# проекте стоят лесенкой»), не отдельно запрошен промптом — пороги
# взяты по аналогии с пулом БД, не проверялись отдельным нагрузочным
# тестом именно на Redis.
- alert: RedisConnectionPoolNearExhaustion
expr: (vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100 > 80
for: 1m
labels:
severity: warning
annotations:
summary: "Пул соединений Redis близок к исчерпанию"
description: >-
Занято {{ $value | printf "%.0f" }}% пула Redis дольше минуты
(порог 80%). Каждое WS-подключение комнаты держит собственную
pub/sub-подписку из этого же пула — смотреть число открытых WS
чата, не только команды Celery/кэша.
# Железо хоста (job `node` — node-exporter). Пороги подобраны под
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
# сменится, пересчитать.

View File

@@ -0,0 +1,173 @@
{
"title": "БД и пулы соединений",
"description": "Доступность БД (vidconf_db_up) и занятость основных пулов (SQLAlchemy/БД, Redis) — метрики читаются вне самих пулов, доступны и при их исчерпании (сессия 33, разбор инцидента 07.08 — .forcc/session-results/32-loadtest-07-08-debug.md). Пороги алертов см. deploy/monitoring/alerts.yml (группа vidconf-db).",
"uid": "vidconf-db-pool",
"editable": false,
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"time": { "from": "now-1h", "to": "now" },
"refresh": "10s",
"tags": ["vidconf", "db", "pool"],
"panels": [
{
"id": 1,
"title": "БД доступна",
"description": "vidconf_db_up — отдельное соединение вне основного пула (core/db.py::check_db_up). Алерт DatabaseUnavailable, for: 30s.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
},
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "refId": "A" }
]
},
{
"id": 2,
"title": "Занятость пула БД сейчас, %",
"description": "vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow) * 100. Порог алерта DbConnectionPoolNearExhaustion — 80% дольше минуты.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100",
"refId": "A"
}
]
},
{
"id": 3,
"title": "Занятость пула Redis сейчас, %",
"description": "vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections * 100. Порог алерта RedisConnectionPoolNearExhaustion — 80% дольше минуты.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Активных алертов группы vidconf-db",
"description": "ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"} — снимок того, что прямо сейчас видит Alertmanager/страница Alerts.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "count(ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"}) OR on() vector(0)",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Занятость пула БД (соединений)",
"description": "vidconf_db_pool_checked_out на фоне вместимости (size + max_overflow) — эта картина должна расти под нагрузочным тестом до срабатывания алерта. Ранний сигнал: в инциденте 07.08 занятость росла постепенно по мере входа участников в комнату, а не рывком от общей HTTP-нагрузки.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_checked_out", "legendFormat": "занято", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_size + vidconf_db_pool_max_overflow", "legendFormat": "вместимость (size+overflow)", "refId": "B" }
]
},
{
"id": 6,
"title": "Занятость пула Redis (соединений)",
"description": "vidconf_redis_pool_in_use на фоне vidconf_redis_pool_max_connections. Второй потолок того же класса, что у БД (закрыт в 0.0.31, redis-py 8 сменил дефолт max_connections на 100).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_in_use", "legendFormat": "занято", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_max_connections", "legendFormat": "вместимость", "refId": "B" }
]
},
{
"id": 7,
"title": "БД доступна во времени",
"description": "vidconf_db_up как временной ряд — удобно видеть провал целиком (начало/длительность отказа), не только текущее состояние.",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": { "drawStyle": "line", "fillOpacity": 20, "lineInterpolation": "stepAfter" },
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } } }
]
},
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "legendFormat": "db_up", "refId": "A" }
]
},
{
"id": 8,
"title": "Сеансы failed / очереди Celery (для сверки с общей нагрузкой)",
"description": "Тот же контекст, что на дашборде «Пайплайны пост-обработки» — здесь рядом с пулами, чтобы не переключаться между дашбордами при разборе инцидента.",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_pipeline_sessions{status=\"failed\"}", "legendFormat": "сеансов failed", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(vidconf_celery_queue_depth)", "legendFormat": "глубина очередей (сумма)", "refId": "B" }
]
}
]
}

View File

@@ -4,7 +4,8 @@
# сервис `prometheus`).
#
# Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`, `vidconf_db_up`,
# `vidconf_db_pool_*`, `vidconf_redis_pool_*`) — КОНТРАКТ с
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
# при переименовании метрик в backend поправить оба файла одновременно.
global:

View File

@@ -170,12 +170,33 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. ---
# --- Медиа (аватары, картинки фона): раздача напрямую из volume, в обход backend. ---
location /media/ {
alias /media/;
autoindex off;
}
# --- Рантайм MediaPipe для замены фона видео (сессия 35) ---
# Ассеты сегментации (wasm-рантайм ~9 МБ + модель ~250 КБ) отдаются СО
# СВОЕГО домена, а не с внешних CDN: VidConf ставят в закрытых контурах,
# где jsdelivr и storage.googleapis.com недоступны, и там фича молча не
# работала бы (см. frontend/src/lib/virtualBackground.ts).
#
# gzip именно здесь, а не на весь сервер: без сжатия wasm едет все 9 МБ,
# со сжатием — около 3. Файлы неизменны в пределах сборки образа и
# скачиваются один раз на браузер, поэтому кэшируются надолго.
location /mediapipe/ {
root /usr/share/nginx/html;
gzip on;
# Модель (.tflite) в список не входит намеренно: внутри она уже
# сжатый архив, gzip дал бы 0% выигрыша за полную цену по CPU.
gzip_types application/wasm application/javascript;
gzip_min_length 1024;
gzip_proxied any;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
# history-fallback на /index.html нужен для клиентского роутинга

View File

@@ -9,16 +9,32 @@
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
# влияющего на эти конфиги) — из корня репозитория:
# ./deploy/render-templates.sh
#
# Источник значений можно подменить переменной ENV_FILE:
# ENV_FILE=.env.local ./deploy/render-templates.sh
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
# через envsubst — расхождение с реальным рендером ждало своего часа.
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
# экспортирует, так что она сюда не протекает).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env"
# Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
# из корня репозитория), абсолютный — как есть.
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
exit 1
fi
echo "[render] источник значений: $ENV_FILE"
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис

View File

@@ -0,0 +1,33 @@
# Дефолтные фоны замены фона видео
Три сцены, которые предлагаются участнику «из коробки» при включённом модуле
«Замена фона» (`instance_settings.virtual_background`):
| файл | сцена |
|---|---|
| `office.svg` | офис: окно с городом, картина, лампа, стеллаж, растение |
| `beach.svg` | пляж: море, песок, пальмы по краям кадра |
| `space-station.svg` | космическая станция: панели, иллюминатор с Землёй |
## Откуда взялись картинки
**Это собственные векторные рисунки VidConf, а не фотографии из интернета.**
Так решено сознательно (решение оператора от 09.08.2026): чужая фотография
тянет за собой чужую лицензию, а продукт самоуправляемый и расходится по
инсталляциям — проверять права на каждую копию некому. У нарисованной сцены
правовых вопросов нет вовсе.
Сцены намеренно расфокусированы (`feGaussianBlur`) и оставляют спокойным центр
кадра: там находится человек, а резкая графика за спиной сильнее всего выдаёт
подмену фона.
## Как пересобрать
```bash
python3 design/backgrounds/render.py
```
Скрипт растеризует каждый `*.svg` в 1280×720 через headless Chrome и кладёт
результат в `frontend/public/backgrounds/*.webp` (именно эти файлы и
раздаются фронтендом). Нужны установленный Google Chrome и Pillow.
Правьте SVG — WebP пересобирается из них, вручную растровые файлы не трогаем.

View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Пляж с пальмами — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Море, песок и пальмы по краям кадра;
центр оставлен спокойным — там находится человек.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7fc4e8"/>
<stop offset="0.55" stop-color="#bfe4f2"/>
<stop offset="1" stop-color="#f2e7c9"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a8fb5"/>
<stop offset="0.5" stop-color="#3aa8c4"/>
<stop offset="1" stop-color="#63c8cf"/>
</linearGradient>
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0dfb8"/>
<stop offset="1" stop-color="#d8bf90"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#8c6239"/>
<stop offset="1" stop-color="#5f4126"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fffbe8" stop-opacity="0.95"/>
<stop offset="1" stop-color="#fff3c4" stop-opacity="0"/>
</radialGradient>
<radialGradient id="beachVignette" cx="0.5" cy="0.45" r="0.8">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#1f4a5c" stop-opacity="0.26"/>
</radialGradient>
<filter id="seaSoft" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="palmSoft" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="7"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#sky)"/>
<ellipse cx="900" cy="150" rx="300" ry="230" fill="url(#sun)"/>
<g filter="url(#seaSoft)">
<!-- Облака -->
<ellipse cx="270" cy="128" rx="130" ry="34" fill="#ffffff" opacity="0.72"/>
<ellipse cx="350" cy="112" rx="88" ry="28" fill="#ffffff" opacity="0.6"/>
<ellipse cx="1010" cy="96" rx="112" ry="28" fill="#ffffff" opacity="0.5"/>
<!-- Море -->
<rect x="0" y="352" width="1280" height="176" fill="url(#sea)"/>
<!-- Блики на воде -->
<g fill="#ffffff" opacity="0.4">
<rect x="120" y="392" width="150" height="6" rx="3"/>
<rect x="360" y="420" width="210" height="6" rx="3"/>
<rect x="700" y="400" width="170" height="6" rx="3"/>
<rect x="980" y="440" width="190" height="6" rx="3"/>
<rect x="240" y="466" width="130" height="5" rx="2.5"/>
<rect x="820" y="474" width="150" height="5" rx="2.5"/>
</g>
<!-- Пена у берега -->
<path d="M0 512c180 26 360-12 540 8s360 34 740-8v36H0z" fill="#ffffff" opacity="0.75"/>
</g>
<!-- Песок -->
<rect x="0" y="530" width="1280" height="190" fill="url(#sand)"/>
<g fill="#c9ac7c" opacity="0.45">
<ellipse cx="300" cy="640" rx="180" ry="18"/>
<ellipse cx="960" cy="682" rx="220" ry="20"/>
</g>
<!-- Пальмы по краям кадра -->
<g filter="url(#palmSoft)">
<g>
<path d="M168 704c-6-190 6-320 40-420l26 8c-30 100-40 226-34 412z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M234 292c-84-30-150-16-196 40 66-16 128-14 186 12z"/>
<path d="M234 292c-58-72-124-96-196-76 62 22 116 58 164 102z"/>
<path d="M240 288c50-72 116-98 190-82-62 26-114 62-158 108z"/>
<path d="M242 296c86-24 154-4 198 56-66-22-128-24-186 4z"/>
<path d="M238 282c14-78-8-140-70-176 26 66 38 128 40 184z"/>
</g>
<g fill="#245f3e">
<path d="M236 300c-46 16-88 46-124 90 48-32 94-52 138-60z"/>
<path d="M240 300c50 14 94 46 130 92-48-34-96-56-142-64z"/>
</g>
<circle cx="228" cy="306" r="12" fill="#7a5a30"/>
<circle cx="252" cy="312" r="11" fill="#7a5a30"/>
</g>
<g>
<path d="M1136 720c10-166 2-278-26-360l-24 8c26 84 34 190 26 352z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M1084 352c72-30 134-20 178 30-60-16-118-14-172 8z"/>
<path d="M1084 352c50-66 110-90 176-72-56 20-106 54-150 96z"/>
<path d="M1080 348c-46-64-104-88-170-74 56 24 102 58 142 100z"/>
<path d="M1078 356c-78-22-140-4-180 50 60-20 116-22 168 4z"/>
</g>
<g fill="#245f3e">
<path d="M1082 360c42 14 80 42 112 82-42-30-84-48-124-56z"/>
</g>
<circle cx="1090" cy="364" r="11" fill="#7a5a30"/>
</g>
</g>
<rect width="1280" height="720" fill="url(#beachVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,130 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Офис — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Светлый переговорный угол: окно с
тёплым светом, стеллаж с книгами и растениями. Центр кадра намеренно спокойный —
там будет человек, детали вынесены к краям.</desc>
<defs>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f3ede4"/>
<stop offset="0.62" stop-color="#e7ded1"/>
<stop offset="1" stop-color="#d9cebe"/>
</linearGradient>
<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c9b49a"/>
<stop offset="1" stop-color="#a98e70"/>
</linearGradient>
<linearGradient id="daylight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fdfaf2"/>
<stop offset="1" stop-color="#e8eef1"/>
</linearGradient>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#b58a5f"/>
<stop offset="1" stop-color="#8d6540"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fff6de" stop-opacity="0.85"/>
<stop offset="1" stop-color="#fff6de" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#3b2d1e" stop-opacity="0.3"/>
</radialGradient>
<!-- Мягкое расфокусирование: фон за спиной человека в кадре камеры никогда
не бывает резким, и резкая графика выдаёт подмену сильнее всего. -->
<filter id="soft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="5"/>
</filter>
<filter id="softer" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#wall)"/>
<g filter="url(#soft)">
<!-- Окно слева -->
<rect x="52" y="86" width="330" height="392" rx="10" fill="#cbb9a3"/>
<rect x="66" y="100" width="302" height="364" rx="6" fill="url(#daylight)"/>
<rect x="212" y="100" width="10" height="364" fill="#cbb9a3"/>
<rect x="66" y="276" width="302" height="10" fill="#cbb9a3"/>
<!-- Городская дымка за стеклом -->
<rect x="66" y="352" width="302" height="112" fill="#dfe6e6" opacity="0.85"/>
<rect x="96" y="300" width="46" height="164" fill="#d3dcde" opacity="0.7"/>
<rect x="158" y="330" width="34" height="134" fill="#cfd8db" opacity="0.6"/>
<rect x="248" y="316" width="52" height="148" fill="#d3dcde" opacity="0.65"/>
<rect x="314" y="344" width="30" height="120" fill="#cfd8db" opacity="0.55"/>
<!-- Стеллаж справа -->
<rect x="876" y="120" width="352" height="392" rx="8" fill="url(#wood)"/>
<rect x="892" y="136" width="320" height="112" fill="#e9dcc9" opacity="0.55"/>
<rect x="892" y="264" width="320" height="112" fill="#e9dcc9" opacity="0.5"/>
<rect x="892" y="392" width="320" height="104" fill="#e9dcc9" opacity="0.45"/>
<!-- Книги -->
<g>
<rect x="906" y="156" width="18" height="92" fill="#8a5a4a"/>
<rect x="928" y="168" width="14" height="80" fill="#6c7f6a"/>
<rect x="946" y="150" width="20" height="98" fill="#c08a4a"/>
<rect x="970" y="172" width="16" height="76" fill="#4f6478"/>
<rect x="990" y="160" width="12" height="88" fill="#9d5f5f"/>
<rect x="1010" y="176" width="22" height="72" fill="#7a6a55"/>
<rect x="906" y="292" width="16" height="84" fill="#5f7382"/>
<rect x="926" y="284" width="20" height="92" fill="#a86f4e"/>
<rect x="950" y="300" width="14" height="76" fill="#7f8f76"/>
<rect x="968" y="288" width="18" height="88" fill="#8d5a63"/>
</g>
<!-- Растение на полке -->
<g>
<path d="M1140 392c-26-14-40-42-34-72 26 6 44 30 44 60z" fill="#4f7a52"/>
<path d="M1150 392c26-16 38-46 30-76-26 8-42 34-40 64z" fill="#5f8f5f"/>
<rect x="1128" y="386" width="40" height="30" rx="5" fill="#b98a63"/>
</g>
<!-- Картина на стене между окном и стеллажом -->
<g>
<rect x="486" y="150" width="196" height="146" rx="6" fill="#c2a882"/>
<rect x="498" y="162" width="172" height="122" fill="#eae3d3"/>
<path d="M498 284l52-58 40 34 44-52 36 76z" fill="#9fb5a2"/>
<circle cx="622" cy="196" r="16" fill="#e8c579"/>
</g>
<!-- Настольная лампа и край стола справа от картины -->
<g>
<rect x="700" y="452" width="220" height="14" rx="4" fill="#c8a878"/>
<rect x="712" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="896" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="792" y="392" width="8" height="60" fill="#6f7b82"/>
<path d="M760 392l36-52 36 52z" fill="#7f8d95"/>
<ellipse cx="796" cy="424" rx="54" ry="18" fill="#ffe9b8" opacity="0.5"/>
</g>
</g>
<!-- Пол -->
<rect x="0" y="512" width="1280" height="208" fill="url(#floor)"/>
<rect x="0" y="506" width="1280" height="14" fill="#e9e0d3" opacity="0.7"/>
<!-- Крупное растение в левом углу — передний план, размыто сильнее -->
<g filter="url(#softer)" opacity="0.95">
<g fill="#3f6b45">
<ellipse cx="88" cy="508" rx="52" ry="34" transform="rotate(-24 88 508)"/>
<ellipse cx="206" cy="556" rx="56" ry="36" transform="rotate(18 206 556)"/>
<ellipse cx="70" cy="596" rx="48" ry="32" transform="rotate(-8 70 596)"/>
</g>
<g fill="#4c7f52">
<ellipse cx="158" cy="486" rx="50" ry="32" transform="rotate(6 158 486)"/>
<ellipse cx="248" cy="626" rx="46" ry="30" transform="rotate(32 248 626)"/>
<ellipse cx="124" cy="574" rx="54" ry="34" transform="rotate(-14 124 574)"/>
</g>
<g stroke="#35603c" stroke-width="7" fill="none" stroke-linecap="round">
<path d="M120 668c-16-56-24-108-32-160"/>
<path d="M132 668c14-52 34-96 62-136"/>
<path d="M126 668c2-44 20-76 46-96"/>
</g>
<rect x="52" y="656" width="150" height="86" rx="14" fill="#9d7550"/>
<rect x="52" y="656" width="150" height="16" rx="8" fill="#b08a63"/>
</g>
<!-- Свет из окна и общая виньетка -->
<ellipse cx="300" cy="250" rx="460" ry="330" fill="url(#glow)"/>
<rect width="1280" height="720" fill="url(#vignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Запекает векторные сцены дефолтных фонов (`*.svg`) в WebP для фронтенда.
Исходники сцен — собственные рисунки VidConf (см. README рядом), поэтому
лежат в репозитории вместе с результатом: чужих фотографий с их лицензиями в
продукте нет.
Запуск (нужен установленный Google Chrome и Pillow):
python3 design/backgrounds/render.py
Результат — `frontend/public/backgrounds/*.webp`, 1280×720. Chrome нужен
только для растеризации SVG (rsvg/ImageMagick на машине может не быть),
Pillow — для перевода PNG в WebP: браузерный скриншот WebP не отдаёт.
"""
import subprocess
import sys
import tempfile
from pathlib import Path
from PIL import Image
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
WIDTH, HEIGHT = 1280, 720
# 82 — визуально неотличимо от исходника на плавных градиентах сцены, а вес
# втрое меньше, чем при 95. Фон грузится по сети при каждом первом показе.
WEBP_QUALITY = 82
SOURCE_DIR = Path(__file__).parent
TARGET_DIR = SOURCE_DIR.parents[1] / "frontend" / "public" / "backgrounds"
def render(svg_path: Path, out_path: Path, workdir: Path) -> None:
"""Растеризовать одну сцену и сохранить её в WebP."""
png_path = workdir / f"{svg_path.stem}.png"
subprocess.run(
[
CHROME,
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--default-background-color=00000000",
f"--screenshot={png_path}",
f"--window-size={WIDTH},{HEIGHT}",
svg_path.resolve().as_uri(),
],
check=True,
capture_output=True,
)
with Image.open(png_path) as image:
image.convert("RGB").save(out_path, "WEBP", quality=WEBP_QUALITY, method=6)
def main() -> int:
if not Path(CHROME).exists():
print(f"не найден Chrome: {CHROME}", file=sys.stderr)
return 1
TARGET_DIR.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp)
for svg_path in sorted(SOURCE_DIR.glob("*.svg")):
out_path = TARGET_DIR / f"{svg_path.stem}.webp"
render(svg_path, out_path, workdir)
print(
f"{svg_path.name}{out_path.relative_to(SOURCE_DIR.parents[1])}"
f" ({out_path.stat().st_size // 1024} КБ)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Космическая станция — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Интерьер модуля станции: панели,
подсветка, иллюминатор с Землёй. Центр кадра спокойный — там человек.</desc>
<defs>
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b3446"/>
<stop offset="0.5" stop-color="#222a3a"/>
<stop offset="1" stop-color="#161c28"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#39445a"/>
<stop offset="1" stop-color="#28304180"/>
</linearGradient>
<linearGradient id="strip" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4fd7ff" stop-opacity="0.1"/>
<stop offset="0.5" stop-color="#7fe8ff" stop-opacity="0.9"/>
<stop offset="1" stop-color="#4fd7ff" stop-opacity="0.1"/>
</linearGradient>
<radialGradient id="earth" cx="0.36" cy="0.32" r="0.78">
<stop offset="0" stop-color="#7fd1f5"/>
<stop offset="0.55" stop-color="#2f7fc4"/>
<stop offset="1" stop-color="#0c2c56"/>
</radialGradient>
<radialGradient id="atmo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0.78" stop-color="#8fd8ff" stop-opacity="0"/>
<stop offset="0.9" stop-color="#8fd8ff" stop-opacity="0.5"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="portGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#8fd8ff" stop-opacity="0.4"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="spaceVignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.5" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#05080f" stop-opacity="0.65"/>
</radialGradient>
<filter id="stationSoft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="stationSofter" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="9"/>
</filter>
<!-- Всё, что «за стеклом», обрезается по стеклу: без этого Земля вылезала
за переплёт иллюминатора и сцена читалась как наклейка на стене. -->
<clipPath id="portholeGlass">
<circle cx="640" cy="318" r="192"/>
</clipPath>
</defs>
<rect width="1280" height="720" fill="url(#hull)"/>
<g filter="url(#stationSoft)">
<!-- Рёбра модуля -->
<g fill="#39445a" opacity="0.55">
<rect x="0" y="0" width="1280" height="52"/>
<rect x="0" y="668" width="1280" height="52"/>
<rect x="150" y="52" width="26" height="616"/>
<rect x="1104" y="52" width="26" height="616"/>
</g>
<!-- Приборные панели слева -->
<g>
<rect x="46" y="150" width="220" height="300" rx="14" fill="url(#panel)"/>
<rect x="66" y="172" width="180" height="86" rx="6" fill="#101826"/>
<g fill="#5ee0a8" opacity="0.85">
<rect x="78" y="230" width="120" height="4" rx="2"/>
<rect x="78" y="216" width="82" height="4" rx="2"/>
<rect x="78" y="202" width="146" height="4" rx="2"/>
</g>
<g>
<circle cx="92" cy="292" r="11" fill="#ff8a5c"/>
<circle cx="126" cy="292" r="11" fill="#ffd166"/>
<circle cx="160" cy="292" r="11" fill="#5ee0a8"/>
</g>
<g fill="#4a5670">
<rect x="66" y="322" width="180" height="16" rx="8"/>
<rect x="66" y="352" width="180" height="16" rx="8"/>
<rect x="66" y="382" width="180" height="16" rx="8"/>
</g>
<rect x="66" y="322" width="104" height="16" rx="8" fill="#7fe8ff" opacity="0.8"/>
<rect x="66" y="352" width="62" height="16" rx="8" fill="#7fe8ff" opacity="0.6"/>
</g>
<!-- Стеллаж оборудования справа -->
<g>
<rect x="1014" y="128" width="230" height="344" rx="14" fill="url(#panel)"/>
<g fill="#101826">
<rect x="1034" y="150" width="190" height="70" rx="6"/>
<rect x="1034" y="238" width="190" height="70" rx="6"/>
<rect x="1034" y="326" width="190" height="70" rx="6"/>
</g>
<g fill="#7fe8ff" opacity="0.6">
<rect x="1050" y="176" width="86" height="5" rx="2.5"/>
<rect x="1050" y="264" width="126" height="5" rx="2.5"/>
<rect x="1050" y="352" width="64" height="5" rx="2.5"/>
</g>
<circle cx="1206" cy="424" r="14" fill="#ff8a5c" opacity="0.8"/>
</g>
<!-- Иллюминатор -->
<g>
<circle cx="640" cy="318" r="212" fill="#39445a"/>
<circle cx="640" cy="318" r="192" fill="#0b1120"/>
<g clip-path="url(#portholeGlass)">
<!-- Звёзды -->
<g fill="#ffffff">
<circle cx="530" cy="200" r="2.5" opacity="0.9"/>
<circle cx="596" cy="168" r="1.8" opacity="0.7"/>
<circle cx="712" cy="188" r="2.2" opacity="0.8"/>
<circle cx="768" cy="252" r="1.6" opacity="0.6"/>
<circle cx="500" cy="300" r="1.8" opacity="0.65"/>
<circle cx="742" cy="356" r="2" opacity="0.7"/>
<circle cx="556" cy="424" r="1.7" opacity="0.6"/>
<circle cx="676" cy="456" r="2.3" opacity="0.75"/>
</g>
<!-- Земля -->
<circle cx="596" cy="386" r="150" fill="url(#earth)"/>
<g fill="#3f8f5f" opacity="0.75">
<path d="M520 330c40-16 74-10 96 14-34 8-64 12-96 6z"/>
<path d="M596 430c46-8 84 6 108 40-42 4-80-8-108-30z"/>
<path d="M498 404c26 6 46 22 58 46-28-4-50-18-66-38z"/>
</g>
<g fill="#ffffff" opacity="0.35">
<ellipse cx="560" cy="352" rx="70" ry="16"/>
<ellipse cx="656" cy="416" rx="86" ry="18"/>
</g>
<circle cx="596" cy="386" r="150" fill="url(#atmo)"/>
</g>
<!-- Переплёт иллюминатора -->
<circle cx="640" cy="318" r="192" fill="none" stroke="#4a5670" stroke-width="14"/>
<circle cx="640" cy="318" r="206" fill="none" stroke="#2b3446" stroke-width="16"/>
<g fill="#4a5670">
<rect x="622" y="98" width="36" height="34" rx="8"/>
<rect x="622" y="504" width="36" height="34" rx="8"/>
<rect x="420" y="300" width="34" height="36" rx="8"/>
<rect x="826" y="300" width="34" height="36" rx="8"/>
</g>
</g>
</g>
<!-- Свечение из иллюминатора и световые полосы модуля -->
<ellipse cx="640" cy="318" rx="420" ry="330" fill="url(#portGlow)"/>
<g filter="url(#stationSofter)">
<rect x="176" y="60" width="928" height="10" rx="5" fill="url(#strip)"/>
<rect x="176" y="650" width="928" height="10" rx="5" fill="url(#strip)"/>
</g>
<rect width="1280" height="720" fill="url(#spaceVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -191,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
@@ -374,6 +382,19 @@ state: connected`, но собеседник не видит видео/не с
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`.
⚠️ **Эта проверка работает только с `verbose` в
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
этого флага coturn пишет в лог только служебные строки старта
(листенеры, `Total auth threads`) и НИКОГДА не логирует
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
и `verbose` в нём нет — добавьте флаг, перерендерите
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
доверять этой проверке.
### TURN over TLS (порт 5349)
@@ -457,7 +478,11 @@ compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
transport policy `relay` в браузере), и убедиться, что аллокации в
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
обычный TURN на 3478 продолжает работать для остальных клиентов.
обычный TURN на 3478 продолжает работать для остальных клиентов
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
отдельной строкой `ALLOCATE processed, success` — можно отличить
TLS-сессию от обычной по времени и по тому, что порт входящего
соединения — 5349).
---

View File

@@ -1,5 +1,9 @@
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
> и готовой таблицы «профиль нагрузки → железо» см.
> [hardware-sizing.md](hardware-sizing.md).
Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),

View File

@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода

View File

@@ -0,0 +1,243 @@
# Профиль нагрузки → рекомендуемое железо (медиа)
Отвечает на вопрос «сколько CPU, RAM и полосы нужно под ожидаемую видео-нагрузку»
для тех, кто разворачивает VidConf у себя. Разговор именно про **медиа**
(конференции, LiveKit SFU) — сайзинг под AI (транскрибация/суммаризация) описан
отдельно: [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md). Базовый пресет инсталлятора
без AI (`4 vCPU / 8 ГБ RAM / 40 ГБ диска`) рассчитан именно на медиа-нагрузку —
этот документ объясняет, какую конференцию такое железо реально держит и когда
его уже мало.
От синтетического нагрузочного теста [capacity.md](capacity.md) (SFU на dev-Mac,
формула по CPU) этот документ отличается источником цифр: здесь — **измерения на
боевом сервере с реальными людьми**, не эмуляция.
⚠️ Все числа ниже — **ориентировочные**, при указанных допущениях. Реальная
нагрузка зависит от поведения людей: сколько включат камеру, будет ли демонстрация
экрана, как долго говорят несколько человек одновременно. Используйте таблицу как
отправную точку для выбора железа, не как гарантию.
## Точка привязки к реальности
Единственные цифры ниже, которые не расчёт, а прямое измерение на боевом сервере
(`4 CPU, 8 ГБ RAM, Ubuntu 24.04`, тот же узел, где сейчас работает `vidconf.ru`):
| Дата | Версия | Участников | Камер | Подписок на видео | Исходящий трафик LiveKit | CPU LiveKit |
|---|---|---|---|---|---|---|
| 28.07.2026 | 0.0.11, **до** `adaptiveStream` | 19 | 17 | ~306 | 140169 Мбит/с устойчиво (пик 240) | 1.62 ядра из 4 |
| 31.07.2026 | 0.0.13, **после** `adaptiveStream`+`dynacast` | 30 | 27 | 783 | **70.6 Мбит/с** | 1.83 ядра из 4 |
Второе измерение и есть якорь для формулы ниже — оно снято на конфигурации,
максимально близкой к дефолтной (пагинация сетки участников, `adaptiveStream`,
`dynacast`), без ручной настройки под тест.
**Почему не пиковые 240 Мбит/с.** Пик — кратковременный всплеск, а не режим, в
котором сервер работал устойчиво; 1.62 ядра CPU намерены именно под устойчивые
140169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
такому коэффициенту недооценит нужное железо.
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
разы, см. следующий раздел.
## Почему нельзя считать «все видят всех»
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
`N × (N1) × битрейт` (каждый участник получает поток от каждого) — при 80
участниках даёт единицы **гигабит в секунду**: недостижимо на одном сервере и не
имеет отношения к тому, что видит пользователь на экране.
Реальная модель другая: клиент подписан не на всех, а на **видимые плитки**, и
получает под каждую подписку битрейт по фактическому размеру плитки — благодаря
пагинации сетки (с релиза 0.0.11) и `adaptiveStream`/`dynacast` (с 0.0.13).
Отсюда рабочая формула:
```
подписок на видео = камер × (участников 1)
```
Она подтверждена обоими измерениями выше **точно**: 17 × 18 = 306, 27 × 29 = 783.
Это формула для типичного «видят всех камер» размещения (сетка без ручного
скрытия участников) — при включённом лимите плиток (см. ниже) число подписок не
растёт дальше лимита, даже если камер больше.
**Насколько наивная формула хуже.** Гипотетически, если бы все 75 участников
большого собрания (профиль ниже) были источником видео и каждый получал полный
поток от каждого — `75 × 74 × 1.5 Мбит/с` (типичный битрейт публикации без
адаптации) — это **9.48 Гбит/с**: недостижимо ни на одном разумном сервере.
Модель выше на сопоставимом масштабе (профиль «Большое собрание», 15 камер из
75) даёт около 182 Мбит/с**в 50 с лишним раз меньше**. Разница — не оптимизация
в мелочах, а другая по порядку величины задача, и именно поэтому SFU вообще
годится для конференций на десятки участников.
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
25 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
## Формула
```
1. подписок = камер × (участников 1)
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
2. видео = подписок × битрейт_на_подписку
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
(часть подписок физически не на экране — почти не потребляет полосы)
3. аудио = активных_микрофонов × участников × 40 кбит/с
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
+ 12 ядра на остальной стек (backend, БД, Redis, coturn, nginx, ОС)
6. RAM ≈ 4 ГБ база (без AI-профилей) — на этом масштабе RAM не была узким
местом ни на одном реальном или синтетическом тесте, планировать по CPU и полосе
```
**Откуда коэффициенты.**
- 450 / 150 кбит/с — измерение одного трека клиентом в крупной и мелкой плитке
(релиз 0.0.13), округлено вверх от 453 и 147 для запаса.
- 100 кбит/с — обратный расчёт по якорному замеру 31.07.2026:
70.6 Мбит/с ÷ 783 подписки ≈ 90 кбит/с в среднем, округлено вверх. Число ниже,
чем «мелкая плитка» (147), потому что в комнате на 30 участников часть из 783
подписок физически не помещалась на текущую страницу сетки — `adaptiveStream`
почти обнулил их битрейт, а среднее по всем подпискам это отражает.
- 90 Мбит/с на ядро — из 28.07.2026: 140169 Мбит/с устойчиво ÷ 1.62 ядра =
86104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
ядер).
- Минимум 2 ядра и запас 12 ядра на остальной стек — эмпирический пол: на
обоих реальных замерах LiveKit не опускался ниже 1.6 ядра независимо от
трафика, а весь остальной стек (backend, Postgres, Redis, coturn, nginx) на
боевом сервере устойчиво укладывается в разницу между занятым LiveKit и 4
доступными ядрами.
⚠️ **Формула по полосе — не единственная граница.** Между двумя замерами
подписок стало в 2.6 раза больше, трафик упал вдвое, а CPU почти не изменился
(1.62 → 1.83 ядра) — то есть процессор тратится в первую очередь на
обработку пакетов/подписок, а не на байты. На сценариях с очень большим числом
мелких подписок (много участников, лимит плиток не выставлен) реальный CPU
может обогнать то, что предсказывает формула по полосе быстрее, чем ожидается
— держите эмпирический пол (2 ядра LiveKit минимум под любую активную
конференцию) и не полагайтесь только на деление на 90.
## Профили нагрузки
Все профили — при `adaptiveStream`+`dynacast` (по умолчанию с 0.0.13) и без
AI-профилей (`transcribe`/`llm`). Допущения по камерам/микрофонам — решение,
не измерение; подставьте свои, если знаете реальный сценарий.
| Профиль | Сценарий | Камер | Подписок | Полоса (видео+аудио) | CPU (LiveKit) | RAM | Узкое место |
|---|---|---|---|---|---|---|---|
| Малая команда | 10 параллельных созвонов по 5 чел., 60% с камерой, 2 микрофона в каждом | 3×10 | 12×10=120 | ~58 Мбит/с | 2 ядра | 4 ГБ | нет — запас большой |
| Совещание | 1 конференция × 25 чел., 70% с камерой, 4 микрофона | 18 | 432 | ~69 Мбит/с | 2 ядра | 4 ГБ | полоса — близко к нашему якорю (70.6 Мбит/с на 30 чел.) |
| Большое собрание | 1 конференция × 75 чел., 20% с камерой (камер меньше лимита плиток), 5 микрофонов | 15 | 1110 | ~182 Мбит/с | 3 ядра | 46 ГБ | полоса и её цена у хостера |
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
LiveKit плюс 12 под остальной стек означают, что 4 vCPU становятся тесными.
### Когда профиль недостижим — и как его спасти
«Большое собрание, все 75 человек с камерой» без ограничений: подписок
75 × 74 = 5550, полоса по коэффициенту пагинации (100 кбит/с) — уже **~555
Мбит/с** только видео, плюс аудио. Такой канал недостижим на типичном железе
и его аренде — это не вопрос выбора сервера мощнее, это упирается в канал
и его стоимость у хостера.
Спасает **лимит плиток на экране** (настройка инстанса «Максимум плиток на
странице», 25/16/9/4, с релиза 0.0.21): он ограничивает число подписок сверху
независимо от числа камер — `min(камер, лимит) × (участников 1)`. При лимите
16 и том же собрании: 16 × 74 = 1184 подписки × 150 кбит/с (все 16 видны
одновременно, без пагинации) ≈ **178 Мбит/с** только видео — втрое меньше, чем
без лимита, но всё ещё требует железа заметно больше базового пресета (3+ ядра
LiveKit по формуле). Лимит делает профиль реалистичным, не дешёвым. Тот же
эффект даёт «Потолок качества публикации» (720p/360p/180p, тот же релиз) —
режет битрейт публикации у источника, а не только у подписчика.
Если и это не помогает — речь уже не про один сервер, а про горизонтальное
масштабирование LiveKit-кластера, вне рамок этого документа.
## Входящая полоса и канал клиента — отдельная история
Таблица выше — **исходящая** полоса сервера (каждому подписчику отдельная
копия), она и есть главный параметр: растёт с числом участников и подписок.
**Входящая** полоса (от клиентов к серверу) на порядок меньше: она равна сумме
битрейтов публикуемых потоков — `камер × ~0.31.5 Мбит/с` (зависит от «Потолка
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
собрания на 75 человек с 15 камерами это 4.522.5 Мбит/с входящих — заметно
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
участников совещания, может упереться в свой исходящий/входящий канал раньше,
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
сетевой инфраструктуры на стороне участников.
## Оговорка про канал и его стоимость
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
оказывается почти всегда не CPU — оба реальных замера показали комфортный
запас (1.61.83 ядра из 4) — а **исходящая полоса и её стоимость у хостера**.
Большинство тарифов VPS считают трафик либо лимитом с доплатой за перебор,
либо по 95-му перцентилю канала; при планировании крупных конференций
сверяйтесь с тарифом хостера на трафик/канал, а не только с числом ядер и
объёмом RAM.
## Как посчитать под свой сценарий
1. Оцените участников (N), долю с камерой, число одновременно активных
микрофонов.
2. `подписок = камер × (N 1)`; если планируете включить лимит плиток —
`min(камер, лимит) × (N 1)`.
3. Выберите битрейт на подписку по разделу «Формула» (450 / 150 / 100 кбит/с)
в зависимости от того, помещаются ли все камеры на одну страницу.
4. `видео = подписок × битрейт`, `аудио = микрофонов × N × 40 кбит/с`,
`полоса = видео + аудио`.
5. `ядер CPU ≈ max(2, ⌈полоса ÷ 90⌉) + 12` на остальной стек.
6. RAM — 4 ГБ база, не растёт заметно с этим масштабом участников (растёт с
выбранным уровнем AI, см. [ADR-004](../architecture/adr/004-ai-tier-matrix.md),
если он используется).
7. Сверьте полосу с тарифом хостера на трафик/канал — часто это упрётся раньше
железа.
## Что может измениться
⚠️ Коэффициент «90 Мбит/с на ядро» и оба якорных замера сняты **до** перевода
LiveKit с диапазона UDP-портов на `rtc.udp_port` (задача «Сеть LiveKit», релиз
0.0.20, задеплоено 02.08.2026) — до этой правки медиатрафик на хосте шёл через
процессы `docker-proxy` (userland-прокси Docker). Сама оптимизация меняет путь
пакетов на хосте, а не логику LiveKit, поэтому полоса из таблиц, скорее всего,
не изменится, а запас по CPU/сети хоста на практике может оказаться больше
указанного здесь. Новый нагрузочный тест с реальными участниками после этой
оптимизации пока не проводился — числа в этом документе консервативны и не
переоценивают требуемое железо, но при появлении нового замера на текущей
сети коэффициенты стоит пересчитать.
## Смотрите также
- [capacity.md](capacity.md) — синтетический нагрузочный тест SFU (`lk load-test`
на dev-Mac) и формула по CPU для верхней оценки ёмкости узла; используйте вместе
с этим документом, если нужна методика для собственного повторного теста.
- [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md) — сайзинг под AI
(транскрибация/суммаризация), отдельно от медиа.
- [monitoring.md](monitoring.md) — как снять реальные цифры со своего сервера
(CPU/RAM/сеть по контейнерам, Grafana).
- [DEPLOYMENT.md](DEPLOYMENT.md) — TURN, порты, что открыть в файрволе под
медиа-трафик.

View File

@@ -20,6 +20,7 @@
"@fullcalendar/timegrid": "^6.1.21",
"@livekit/components-react": "^2.9.23",
"@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1",
@@ -1220,6 +1221,25 @@
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@livekit/track-processors": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
},
"peerDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1921,6 +1941,23 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/dom-webcodecs": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT",
"peer": true
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",

View File

@@ -22,6 +22,7 @@
"@fullcalendar/timegrid": "^6.1.21",
"@livekit/components-react": "^2.9.23",
"@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,16 @@
selfie_segmenter.tflite
=======================
Модель сегментации силуэта человека MediaPipe Selfie Segmenter (float16),
используется заменой фона видео (см. frontend/src/lib/virtualBackground.ts).
Источник: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite
Документация: https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
Правообладатель: Google LLC
Лицензия: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
Файл лежит в репозитории намеренно: npm-пакетом модель не поставляется, а
VidConf — самоуправляемый продукт и обязан работать в контуре без доступа к
внешним CDN. Wasm-рантайм MediaPipe (тоже Apache 2.0) в репозиторий не
коммитится — он копируется в public/mediapipe/wasm/ из node_modules при
сборке, см. плагин `mediapipeWasm` в frontend/vite.config.ts.

Binary file not shown.

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { JoinPage } from '@/pages/JoinPage'
import { RoomPage } from '@/pages/RoomPage'
@@ -19,6 +20,10 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
{/* Публичная страница регламента обработки ПДн — читается до регистрации,
когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия
на RegisterPage. */}
<Route path="/legal/personal-data-consent" element={<ConsentPolicyPage />} />
<Route
path="/lobby"
element={

View File

@@ -19,6 +19,8 @@ export interface AiLevelStatus {
/** Эффективные настройки инстанса. */
export interface SettingsOut {
chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
hand_queue_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
@@ -43,11 +45,24 @@ export interface SettingsOut {
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
/** Обязательна ли галочка согласия на обработку персональных данных при регистрации. */
consent_required: boolean
/** Текст регламента (редактируемый шаблон, дефолт — типовой образец без юридической силы). */
consent_policy_text: string
/** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */
consent_policy_version: number
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на странице логина и в карточке «Как вас зовут?». */
device_check_enabled: boolean
/** Замена фона видео на картинку (сессия 35) — кнопка «Фон» в комнате,
* выбор фона в превью на входе и раздел «Свои фоны» в профиле. */
virtual_background_enabled: boolean
}
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn {
chat_enabled?: boolean
hand_queue_enabled?: boolean
transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel
@@ -62,6 +77,11 @@ export interface SettingsUpdateIn {
contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
/** Включение с пустым текстом регламента — backend отвечает 400. */
consent_required?: boolean
consent_policy_text?: string
device_check_enabled?: boolean
virtual_background_enabled?: boolean
}
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -9,6 +9,8 @@ export interface RegisterPayload {
password: string
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */
team_id?: string | null
/** Согласие на обработку персональных данных — обязано быть `true`, если `consent_required`. */
consent_accepted?: boolean
}
/** Команда, доступная для выбора на экране регистрации. */
@@ -23,6 +25,12 @@ export interface RegistrationOptions {
teams: RegistrationTeamOption[]
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[]
/** Обязательна ли галочка согласия на обработку персональных данных на форме регистрации. */
consent_required: boolean
/** Текст регламента — отдаётся всегда, независимо от `consent_required` (нужен и странице регламента). */
consent_text: string
/** Номер редакции текста, с которой согласится пользователь при регистрации. */
consent_version: number
}
export interface CurrentUser {

View File

@@ -4,8 +4,15 @@
* - Access-токен подставляется из authStore (память, не localStorage).
* - На 401 выполняется один silent-refresh (POST /auth/refresh,
* credentials: 'include' — сессия читается из httpOnly-cookie) и повтор
* исходного запроса. Если refresh не удался — access-токен сбрасывается и
* выполняется редирект на /login.
* исходного запроса.
* - ⚠️ Причина неудачи refresh различается (`RefreshOutcome`). Сессия
* сбрасывается ТОЛЬКО когда backend сказал, что она недействительна
* (`invalid`). Ответ 5xx или обрыв сети — это «серверу плохо», а не «вы не
* авторизованы»: токен сохраняется, пользователь остаётся в системе и
* получает обычную ошибку запроса. Раньше различия не было, и на
* нагрузочном тесте 07.08.2026 (когда refresh отвечал 500 из-за
* исчерпанного пула БД) фронтенд разлогинивал людей посреди работы, а
* повторный вход падал тем же 500.
* - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise).
*/
import { authStore } from '@/auth/authStore'
@@ -44,26 +51,48 @@ interface RequestOptions extends Omit<RequestInit, 'body'> {
skipAuthRefresh?: boolean
}
let refreshPromise: Promise<boolean> | null = null
/**
* Итог silent-refresh.
*
* - `ok` — выдан новый access-токен;
* - `invalid` — backend отверг refresh-сессию (просрочена, отозвана, reuse):
* единственный случай, когда пользователя правда надо разлогинить;
* - `unavailable` — до ответа «сессия недействительна» дело не дошло: 5xx,
* таймаут или обрыв сети. Сессия при этом цела, `status` — HTTP-код
* ответа или `null`, если запрос не доехал вовсе.
*/
export type RefreshOutcome =
| { result: 'ok' }
| { result: 'invalid' }
| { result: 'unavailable'; status: number | null }
let refreshPromise: Promise<RefreshOutcome> | null = null
/**
* Выполняет silent-refresh access-токена через httpOnly refresh-cookie.
* Возвращает true при успехе. Параллельные вызовы переиспользуют один запрос.
* Параллельные вызовы переиспользуют один запрос.
*/
export async function refreshAccessToken(): Promise<boolean> {
export async function refreshAccessToken(): Promise<RefreshOutcome> {
if (!refreshPromise) {
refreshPromise = (async () => {
refreshPromise = (async (): Promise<RefreshOutcome> => {
try {
const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!response.ok) return false
if (response.ok) {
const data = (await response.json()) as { access_token: string }
authStore.setAccessToken(data.access_token)
return true
return { result: 'ok' }
}
// Про недействительность сессии backend говорит только кодом 4xx.
// Всё остальное (500/502/503/504) — состояние сервера, а не сессии.
return response.status >= 500
? { result: 'unavailable', status: response.status }
: { result: 'invalid' }
} catch {
return false
// Сеть не доехала — про сессию мы так ничего и не узнали.
return { result: 'unavailable', status: null }
} finally {
refreshPromise = null
}
@@ -124,9 +153,17 @@ export async function apiRequest<T = unknown>(path: string, options: RequestOpti
let response = await doFetch()
if (response.status === 401 && !skipAuthRefresh) {
const refreshed = await refreshAccessToken()
if (refreshed) {
const outcome = await refreshAccessToken()
if (outcome.result === 'ok') {
response = await doFetch()
} else if (outcome.result === 'unavailable') {
// Серверу плохо — сессию не трогаем и на /login не выкидываем:
// как только backend оживёт, следующий запрос обновит токен сам.
throw new ApiError(
outcome.status ?? 0,
null,
'Сервер временно недоступен. Попробуйте ещё раз через минуту.',
)
} else {
redirectToLogin()
throw new ApiError(401, null, 'Сессия истекла')

View File

@@ -61,10 +61,14 @@ export interface ConferenceJoinData {
conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
/** Включён ли модуль «замена фона» — при `false` кнопка «Фон» в тулбаре не рендерится. */
virtual_background_enabled: boolean
}
/**

View File

@@ -0,0 +1,16 @@
/** Публичные настройки инстанса — доступны без аутентификации (`GET /public/settings`). */
import { apiRequest } from '@/api/client'
/** Флаги, нужные странице логина и гостевой карточке входа до аутентификации. */
export interface PublicSettingsOut {
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на LoginPage/JoinPage. */
device_check_enabled: boolean
/** Замена фона видео (сессия 35) — нужен превью на JoinPage; участнику в
* комнате тот же флаг приезжает в join-ответе (`JoinOut`). */
virtual_background_enabled: boolean
}
export async function getPublicSettings(): Promise<PublicSettingsOut> {
return apiRequest<PublicSettingsOut>('/public/settings', { skipAuthRefresh: true })
}

View File

@@ -62,6 +62,42 @@ export async function deleteMyAvatar(): Promise<void> {
await apiRequest('/users/me/avatar', { method: 'DELETE' })
}
/** Своя картинка пользователя для замены фона видео. */
export interface MyBackground {
id: string
/** Путь на своём домене (`/media/backgrounds/...`), раздаёт nginx. */
url: string
}
/** Ответ списка своих картинок фона — вместе с серверным лимитом на их число. */
export interface MyBackgroundsResponse {
items: MyBackground[]
limit: number
}
/** Свои картинки фона текущего пользователя. Гостю недоступно (401) — у него нет профиля. */
export async function listMyBackgrounds(): Promise<MyBackgroundsResponse> {
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds')
}
/**
* Загрузить свою картинку фона; возвращается весь список заново.
*
* Картинка ужимается ДО отправки (`lib/imageResize.ts`) — сервер её не
* пережимает (Pillow на backend нет). Коды ошибок: 409 — упёрлись в лимит,
* 413 — файл больше 2 МБ, 415 — недопустимый тип.
*/
export async function uploadMyBackground(file: File | Blob): Promise<MyBackgroundsResponse> {
const form = new FormData()
form.append('file', file, 'background.webp')
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds', { method: 'POST', body: form })
}
/** Удалить свою картинку фона — 204 без тела; файл на диске удаляется вместе с записью. */
export async function deleteMyBackground(id: string): Promise<void> {
await apiRequest(`/users/me/backgrounds/${id}`, { method: 'DELETE' })
}
/** Тело смены пароля текущего пользователя. */
export interface PasswordChangePayload {
current_password: string

View File

@@ -4,6 +4,20 @@ import { authStore } from '@/auth/authStore'
import { refreshAccessToken } from '@/api/client'
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext'
/**
* Задержки повторов восстановления сессии, если backend отвечает 5xx.
*
* Недоступность сервера — не повод объявлять пользователя неавторизованным:
* refresh-cookie цела, и через несколько секунд сессия обычно поднимается
* сама. Повторов ровно три (суммарно ~7 с) — дальше показываем страницу
* входа, потому что бесконечный спиннер хуже честного «войдите заново»:
* cookie при этом не стирается, и повторная попытка входа сработает, как
* только backend оживёт.
*/
const BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000]
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* Провайдер сессии пользователя.
* При монтировании приложения пытается восстановить сессию через
@@ -18,9 +32,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let cancelled = false
async function bootstrap() {
const restored = await refreshAccessToken()
let outcome = await refreshAccessToken()
for (const delay of BOOTSTRAP_RETRY_DELAYS_MS) {
if (cancelled || outcome.result !== 'unavailable') break
await sleep(delay)
if (cancelled) return
if (!restored) {
outcome = await refreshAccessToken()
}
if (cancelled) return
if (outcome.result !== 'ok') {
setStatus('unauthenticated')
return
}

View File

@@ -68,6 +68,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [deviceCheckEnabled, setDeviceCheckEnabled] = useState(data.device_check_enabled)
const [virtualBackgroundEnabled, setVirtualBackgroundEnabled] = useState(
data.virtual_background_enabled,
)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -80,6 +85,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
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 [consentRequired, setConsentRequired] = useState(data.consent_required)
const [consentPolicyText, setConsentPolicyText] = useState(data.consent_policy_text)
const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -91,7 +98,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
toast.show(
errorDetail(err) ??
'Недоступное значение — проверьте уровень AI, таймзону, домен почты и текст регламента',
'error',
)
} else {
toast.show('Не удалось сохранить настройки', 'error')
}
@@ -132,6 +143,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
// валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (deviceCheckEnabled !== data.device_check_enabled) payload.device_check_enabled = deviceCheckEnabled
if (virtualBackgroundEnabled !== data.virtual_background_enabled)
payload.virtual_background_enabled = virtualBackgroundEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -154,6 +169,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
if (consentRequired !== data.consent_required) payload.consent_required = consentRequired
if (consentPolicyText !== data.consent_policy_text) payload.consent_policy_text = consentPolicyText
mutation.mutate(payload)
}
@@ -175,6 +192,60 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Поднятие руки</strong>
<span>Кнопка «Рука» и очередь поднятых рук в комнате целиком</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={handQueueEnabled}
onChange={(e) => setHandQueueEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Проверка устройств на входе</strong>
<span>
Запрос доступа к камере и микрофону и превью камеры на странице входа и в
карточке «Как вас зовут?» чтобы разрешение не выскакивало уже внутри
конференции. Микрофон и камера в самой конференции по-прежнему выключены при
входе
</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={deviceCheckEnabled}
onChange={(e) => setDeviceCheckEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Замена фона видео</strong>
<span>
Выбор фона в конференции: три готовые сцены и свои картинки (до 10 штук,
загружаются в профиле). Работает только на компьютере фон считает нейросеть
на каждом кадре, и на телефоне это греет устройство и просаживает встречу
</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={virtualBackgroundEnabled}
onChange={(e) => setVirtualBackgroundEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong>
@@ -461,6 +532,55 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div>
</section>
<section className="settings-card">
<h2>Согласие на обработку персональных данных</h2>
<p className="desc">
Галочка на форме регистрации со ссылкой на регламент (страница{' '}
<code>/legal/personal-data-consent</code>). Факт согласия хранится в БД вместе с
номером редакции текста и датой.
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Требовать согласие при регистрации</strong>
<span>Без отмеченной галочки кнопка регистрации неактивна, сервер тоже откажет</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={consentRequired}
onChange={(e) => setConsentRequired(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-consent-text">
Текст регламента редакция {data.consent_policy_version}
</label>
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" /> Дефолтный
текст типовой образец, не проходил проверку юриста. Замените плейсхолдеры в
квадратных скобках (наименование оператора, адрес, контакты, цели и срок
обработки) под свою организацию, прежде чем включать требование согласия.
</p>
<textarea
id="settings-consent-text"
rows={12}
value={consentPolicyText}
onChange={(e) => setConsentPolicyText(e.target.value)}
style={{ width: '100%', fontFamily: 'inherit', resize: 'vertical' }}
/>
<p className="field-hint">
Сохранение изменённого текста автоматически увеличивает номер редакции это
значение фиксируется у каждого пользователя вместе с датой согласия.
</p>
</div>
</div>
</section>
<section className="settings-card">
<h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css'
interface DeviceCheckCardProps {
/** Коллбэк-реф из `useDeviceCheckAccess` — см. докстринг там: карточка
* может рендериться в разных местах JSX-дерева (разные шаги `JoinPage`),
* обычный объект-реф не пережил бы такой переезд. */
videoRef: (node: HTMLVideoElement | null) => void
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
hint: string | null
/** Одновременно и «войти с камерой/микрофоном включёнными», и (для видео)
* реальное состояние превью — см. докстринг `useDeviceCheckAccess`. */
videoEnabled: boolean
audioEnabled: boolean
onToggleVideo: () => void
onToggleAudio: () => void
/**
* Выбор фона под кнопками (сессия 35) — приходит готовым узлом, а не набором
* пропсов: карточка про доступ к устройствам и знать про фоны, их лимиты и
* загрузку не обязана. `undefined` — модуль выключен, устройство не
* десктопное либо браузер не умеет сегментацию; тогда блока нет вовсе.
*/
backgroundPicker?: ReactNode
}
/**
* Превью камеры + переключатели «микрофон/камера» — окошко превью и кнопки
* составляют одну композицию (кнопки не шире окошка, см. `device-check.css`).
* Кнопки без подписи — только пиктограмма (иначе не умещаются под окошком
* шириной в половину карточки); машиночитаемое состояние — `aria-label`.
* Кликабельны только после реального разрешения (`granted`) — до этого
* непонятно, что вообще включать.
*
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — коллбэк-реф
* хука подключает `srcObject` при КАЖДОМ монтировании узла (см. докстринг
* `useDeviceCheckAccess`); если бы элемент монтировался условно, в момент
* присвоения его ещё не было бы в DOM и поток повис бы никуда не
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку.
*
* `muted`+`playsInline`+`autoPlay` — обязательны: без них iOS Safari не
* запустит воспроизведение живого потока, и вместо своего лица пользователь
* увидит чёрный прямоугольник.
*/
export function DeviceCheckCard({
videoRef,
videoStatus,
audioStatus,
hint,
videoEnabled,
audioEnabled,
onToggleVideo,
onToggleAudio,
backgroundPicker,
}: DeviceCheckCardProps) {
const showPlaceholder = videoStatus !== 'granted' || !videoEnabled
return (
<div className="device-check">
<div className="device-check-inner">
<div className="device-check-frame">
<video ref={videoRef} muted playsInline autoPlay className="device-check-video" />
{showPlaceholder && (
<div className="device-check-placeholder" aria-hidden="true">
<VideoOff className="lucide" />
</div>
)}
</div>
<div className="device-check-toggles">
<button
type="button"
className={`device-check-toggle${audioEnabled ? ' is-on' : ''}`}
disabled={audioStatus !== 'granted'}
onClick={onToggleAudio}
aria-pressed={audioEnabled}
aria-label={audioEnabled ? 'Войти с выключенным микрофоном' : 'Войти с включённым микрофоном'}
>
{audioEnabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
</button>
<button
type="button"
className={`device-check-toggle${videoEnabled ? ' is-on' : ''}`}
disabled={videoStatus !== 'granted'}
onClick={onToggleVideo}
aria-pressed={videoEnabled}
aria-label={videoEnabled ? 'Выключить камеру' : 'Включить камеру'}
>
{videoEnabled ? <Video className="lucide" aria-hidden="true" /> : <VideoOff className="lucide" aria-hidden="true" />}
</button>
</div>
</div>
{backgroundPicker && <div className="device-check-backgrounds">{backgroundPicker}</div>}
{hint && <p className="field-hint device-check-hint">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Ban, Check, Loader2 } from 'lucide-react'
import type { MyBackground } from '@/api/users'
import { DEFAULT_BACKGROUNDS, NO_BACKGROUND, type BackgroundKey } from '@/lib/virtualBackground'
import '@/styles/virtual-background.css'
interface BackgroundPickerProps {
value: BackgroundKey
onChange: (key: BackgroundKey) => void
/** Свои картинки пользователя; у гостя список всегда пуст — профиля у него нет. */
customBackgrounds: MyBackground[]
/** Идёт применение фона (первый раз — ещё и загрузка модели) — показать индикатор на выбранной плитке. */
busy?: boolean
}
/**
* Сетка выбора фона: «без фона», три дефолтные сцены и свои картинки.
*
* Один компонент на все три места, где выбирают фон (превью входа, комната,
* профиль): плитки везде выглядят одинаково, а цвета берутся из токенов темы —
* внутри комнаты (`[data-theme="room"]`) те же классы перекрашиваются в тёмную
* палитру, см. `styles/virtual-background.css`.
*
* Кнопки — `aria-pressed`, а не радиогруппа: выбор применяется немедленно и
* никуда не отправляется формой.
*/
export function BackgroundPicker({
value,
onChange,
customBackgrounds,
busy = false,
}: BackgroundPickerProps) {
const renderTile = (key: BackgroundKey, label: string, url: string | null) => {
const selected = value === key
return (
<button
key={key}
type="button"
className={`bg-tile${selected ? ' is-selected' : ''}`}
aria-pressed={selected}
aria-label={label}
title={label}
onClick={() => onChange(key)}
>
{url ? (
<img src={url} alt="" loading="lazy" />
) : (
<span className="bg-tile-none">
<Ban className="lucide" aria-hidden="true" />
</span>
)}
{selected && (
<span className="bg-tile-mark" aria-hidden="true">
{busy ? (
<Loader2 className="lucide bg-tile-spinner" />
) : (
<Check className="lucide" />
)}
</span>
)}
</button>
)
}
return (
<div className="bg-grid">
{renderTile(NO_BACKGROUND, 'Без фона', null)}
{DEFAULT_BACKGROUNDS.map((item) => renderTile(`default:${item.id}`, item.label, item.url))}
{customBackgrounds.map((item, index) =>
renderTile(`custom:${item.id}`, `Своя картинка ${index + 1}`, item.url),
)}
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { deleteMyBackground, uploadMyBackground } from '@/api/users'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
import { MY_BACKGROUNDS_QUERY_KEY, useMyBackgrounds } from '@/hooks/useMyBackgrounds'
import { ImageDecodeError, resizeImageForBackground } from '@/lib/imageResize'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import '@/styles/virtual-background.css'
/** Что вообще можно выбрать в диалоге файлов — совпадает с проверкой на сервере. */
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp'
/**
* Секция профиля «Свои фоны» — загрузка и удаление картинок для замены фона
* видео («личный кабинет» из задачи).
*
* Загрузка идёт с предварительным СЖАТИЕМ в браузере (`lib/imageResize.ts`):
* «ужимать до приемлемого размера, чтобы не грузили БД» — требование задачи.
* Сервер картинку не пережимает, но полноценно валидирует.
*
* Лимит на число картинок приходит с сервера (`limit` в ответе списка) — он
* там же и проверяется; здесь кнопка просто гаснет заранее, чтобы не отправлять
* запрос, заведомо обречённый на 409.
*
* Удаление применённого сейчас фона отдельно обрабатывать не нужно: выбор
* хранится ключом `custom:<id>`, и как только записи с таким id не стало,
* `useVirtualBackground` сам сбрасывает фон на «без фона».
*/
export function MyBackgroundsSection() {
const toast = useToast()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
const { items, limit } = useMyBackgrounds(true)
// Раздел показывается на любом устройстве (загрузить картинки заранее с
// телефона — нормальный сценарий), но на не-десктопе честно предупреждаем,
// что применить фон получится только на компьютере: см. `isDesktopDevice`.
const [desktop] = useState(isDesktopDevice)
const invalidate = () => queryClient.invalidateQueries({ queryKey: MY_BACKGROUNDS_QUERY_KEY })
const uploadMutation = useMutation({
mutationFn: async (file: File) => uploadMyBackground(await resizeImageForBackground(file)),
onSuccess: async () => {
await invalidate()
toast.show('Фон добавлен', 'success')
},
onError: (err: unknown) => {
if (err instanceof ImageDecodeError) {
setError('Не удалось прочитать картинку — возможно, файл повреждён')
} else if (err instanceof ApiError && err.status === 409) {
setError(`Больше ${limit} картинок хранить нельзя — удалите ненужную`)
} else if (err instanceof ApiError && err.status === 413) {
setError('Файл слишком большой даже после сжатия')
} else if (err instanceof ApiError && err.status === 415) {
setError('Недопустимый формат — только JPEG, PNG или WEBP')
} else {
toast.show('Не удалось загрузить фон', 'error')
}
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteMyBackground(id),
onSuccess: async () => {
await invalidate()
toast.show('Фон удалён', 'success')
},
onError: () => toast.show('Не удалось удалить фон', 'error'),
})
const limitReached = limit > 0 && items.length >= limit
const busy = uploadMutation.isPending || deleteMutation.isPending
return (
<section className="profile-card">
<h2>Свои фоны</h2>
<p className="field-hint" style={{ margin: 0 }}>
Картинки для замены фона видео в конференции. Не больше {limit || 10} штук; перед
отправкой картинка автоматически уменьшается.
{!desktop && ' Сама замена фона работает только на компьютере — на телефоне она' +
' отключена, чтобы не нагружать устройство.'}
</p>
{items.length > 0 && (
<div className="bg-manage-grid">
{items.map((item, index) => (
<div key={item.id} className="bg-manage-tile">
<img src={item.url} alt={`Свой фон ${index + 1}`} loading="lazy" />
<button
type="button"
className="bg-tile-delete"
aria-label={`Удалить свой фон ${index + 1}`}
disabled={busy}
onClick={() => {
setError(null)
deleteMutation.mutate(item.id)
}}
>
<Trash2 className="lucide" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
<div className="profile-avatar-actions">
<button
type="button"
className="btn btn-secondary"
disabled={busy || limitReached}
onClick={() => fileInputRef.current?.click()}
>
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
{uploadMutation.isPending ? 'Загружаем…' : 'Добавить фон'}
</button>
<span className="field-hint">
{items.length} из {limit || 10}
</span>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
e.target.value = ''
setError(null)
if (file) uploadMutation.mutate(file)
}}
/>
</div>
{error && (
<p className="field-hint" style={{ color: 'var(--color-danger)', margin: 0 }}>
{error}
</p>
)}
</section>
)
}

View File

@@ -0,0 +1,77 @@
import { X } from 'lucide-react'
import type { MyBackground } from '@/api/users'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { BackgroundPicker } from '@/components/background/BackgroundPicker'
import type { BackgroundKey } from '@/lib/virtualBackground'
import type { VirtualBackgroundStatus } from '@/hooks/useVirtualBackground'
interface BackgroundDialogProps {
onClose: () => void
value: BackgroundKey
onChange: (key: BackgroundKey) => void
customBackgrounds: MyBackground[]
status: VirtualBackgroundStatus
/** Гость своих картинок иметь не может — ему показывается другая подсказка. */
canManageOwn: boolean
}
/**
* Модалка «Фон» в комнате — та же оболочка, что у `DeviceSettingsDialog`
* (`.room-modal-*`), внутри общая сетка выбора (`BackgroundPicker`).
*
* Мобильного варианта-шторки здесь НЕТ намеренно: фича десктопная (см.
* `lib/isDesktopDevice.ts`), и на телефоне ни кнопки, ни этой модалки не
* существует вовсе.
*
* Свои картинки отсюда не загружаются — они живут в профиле («личный кабинет»
* из задачи). В комнате их можно только выбрать: загрузка требует ухода со
* страницы, а бросать конференцию ради этого не нужно.
*/
export function BackgroundDialog({
onClose,
value,
onChange,
customBackgrounds,
status,
canManageOwn,
}: BackgroundDialogProps) {
useModalDismiss(onClose)
return (
<div
className="room-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="background-dialog-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="room-modal-head">
<h2 id="background-dialog-title">Фон</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<BackgroundPicker
value={value}
onChange={onChange}
customBackgrounds={customBackgrounds}
busy={status === 'loading'}
/>
{status === 'error' ? (
<p className="bg-hint">
Не удалось применить фон попробуйте выбрать другой или отключить фон.
</p>
) : (
<p className="bg-hint">
{canManageOwn
? 'Свои картинки добавляются в профиле — их видно здесь сразу после загрузки.'
: 'Свои картинки доступны зарегистрированным пользователям.'}
</p>
)}
</div>
</div>
)
}

View File

@@ -11,7 +11,7 @@ interface DeviceSettingsDialogProps extends StageViewProps {
onClose: () => void
}
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */
/** Свайп панели вниз дальше этого порога (px) закрывает шторку, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
@@ -30,7 +30,13 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
* `deviceId` при следующем подключении.
*
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку.
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз по ЛЮБОМУ
* месту панели (не только за ручку-волосок `.room-sheet-handle` — та её не
* пережила бы: 40×4px, попасть пальцем почти нереально). Свайп срабатывает,
* только если содержимое панели проскроллено в самый верх (`scrollTop === 0`
* на touchstart, см. `dragEligibleRef`) — иначе палец должен листать список
* устройств, а не закрывать окно; решение фиксируется на весь жест, а не
* пересчитывается на каждый touchmove, как в любом стандартном bottom sheet.
* Десктоп не меняется.
*
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
@@ -61,6 +67,12 @@ export function DeviceSettingsDialog({
useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null)
// Жест начат, когда контент панели был проскроллен в самый верх — свайп по
// панели, у которой ещё есть что скроллить, должен листать содержимое, а не
// закрывать шторку. Решается ОДИН раз в touchstart и держится весь жест
// (даже если внутри него направление сменится) — так же, как в любом
// стандартном bottom sheet.
const dragEligibleRef = useRef(false)
const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false)
@@ -92,18 +104,21 @@ export function DeviceSettingsDialog({
}
}
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) {
function handlePanelTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragEligibleRef.current = e.currentTarget.scrollTop === 0
if (!dragEligibleRef.current) return
dragStartYRef.current = e.touches[0].clientY
setIsDragging(true)
}
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (dragStartYRef.current === null) return
function handlePanelTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (!dragEligibleRef.current || dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta)
}
function handleHandleTouchEnd() {
function handlePanelTouchEnd() {
if (!dragEligibleRef.current) return
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose()
return
@@ -124,20 +139,16 @@ export function DeviceSettingsDialog({
<div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()}
onTouchStart={isCompact ? handlePanelTouchStart : undefined}
onTouchMove={isCompact ? handlePanelTouchMove : undefined}
onTouchEnd={isCompact ? handlePanelTouchEnd : undefined}
style={
isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined
}
>
{isCompact && (
<div
className="room-sheet-handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
/>
)}
{isCompact && <div className="room-sheet-handle" />}
<div className="room-modal-head">
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react'
import { useLocalParticipant } from '@livekit/components-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat'
@@ -9,8 +10,15 @@ interface HandQueueMenuProps {
}
/**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам
* (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор).
* Опустить чужую запись может по-прежнему только организатор — обычный
* участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не
* видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё
* равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая
* кнопка, которая молча не работает, хуже отсутствующей.
*
* Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её
@@ -19,11 +27,12 @@ interface HandQueueMenuProps {
* Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече
* получил бы поповер выше экрана.
* скроллится (см. room.css) — иначе участник на энергичной встрече получил
* бы поповер выше экрана.
*/
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
@@ -47,8 +56,6 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
}
}, [open])
if (!isOrganizer) return null
return (
<div className="tb-menu-wrap" ref={wrapRef}>
<button
@@ -74,13 +81,17 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
<p className="chat-empty">Пока никто не поднял руку</p>
) : (
<ol className="hand-queue-list">
{queue.map((entry, index) => (
{queue.map((entry, index) => {
const isOwn = entry.identity === localParticipant.identity
const canLower = isOrganizer || isOwn
return (
<li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" />
{entry.name}
</span>
{canLower && (
<button
type="button"
className="hand-queue-lower"
@@ -88,8 +99,10 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
>
Опустить
</button>
)}
</li>
))}
)
})}
</ol>
)}
</div>

View File

@@ -126,8 +126,10 @@ function TileBody({
const showSharingChip = Boolean(
onStopSharing && trackReference.source === Track.Source.ScreenShare && trackReference.participant.isLocal,
)
// Кнопка закрепления — только там, где сцена умеет закрепление (основное
// окно передаёт `onTogglePin`; в мини-плеере плитка одна, закреплять нечего).
// Кнопка закрепления — только там, где сцена её даёт (основное окно передаёт
// `onTogglePin`; в мини-плеере своего тулбара нет и плитка одна, поэтому
// булавки там нет — само закрепление, сделанное в основном окне, с 0.0.25
// действует и в мини-плеере, см. `initialPinnedKey` в `RoomStage`).
// Ключ плитки берём из её собственного трека: в карусели/гриде плитки
// рендерятся шаблоном без пропсов, снаружи «какая это плитка» не передать.
const tileKey = stageTrackKey(trackReference)
@@ -200,10 +202,8 @@ function TileBody({
type="button"
className={`room-pin-toggle${isPinned ? ' is-pinned' : ''}`}
aria-pressed={isPinned}
title={isPinned ? 'Открепить' : 'Закрепить в основном окне'}
aria-label={
isPinned ? `Открепить: ${displayName}` : `Закрепить в основном окне: ${displayName}`
}
title={isPinned ? 'Открепить' : 'Закрепить'}
aria-label={isPinned ? `Открепить: ${displayName}` : `Закрепить: ${displayName}`}
onClick={(e) => {
// Иначе клик долетит до самой плитки (`onParticipantClick`
// у `ParticipantTile`) — булавка не должна означать «клик по плитке».

View File

@@ -1,10 +1,9 @@
import { useEffect, useState, type ReactNode } from 'react'
import { EyeOff, Mic, MicOff, Users } from 'lucide-react'
import { EyeOff, Mic, MicOff, ScreenShare, ScreenShareOff, Users } from 'lucide-react'
import { Track, type Participant } from 'livekit-client'
import {
CarouselLayout,
FocusLayoutContainer,
RoomAudioRenderer,
isTrackReference,
useRoomContext,
useSpeakingParticipants,
@@ -14,8 +13,10 @@ import {
} from '@livekit/components-react'
import { RoomParticipantTile } from '@/components/room/RoomParticipantTile'
import { StageGrid } from '@/components/room/StageGrid'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { pickStageFocus, stageTrackKey } from '@/components/room/stageFocus'
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
import type { StageLayoutMode } from '@/lib/stageLayoutMode'
/**
@@ -44,9 +45,11 @@ const STAGE_TRACK_SOURCES = [
]
/**
* Удержание фокуса основного окна при смене говорящего, мс.
* Удержание фокуса при смене говорящего, мс. Действует в ОБОИХ вариантах
* сцены — и в основном окне, и в мини-плеере (до 0.0.25 в PiP удержания не
* было вовсе, фокус там переключался мгновенно).
*
* Основное окно следует за спикером (`followSpeaker`, задача 3.2), и без
* Сцена следует за спикером (`followSpeaker`, задача 3.2), и без
* удержания короткие реплики («ага», «угу») уводили бы большую плитку на
* секунду и возвращали обратно. Источник говорящих (`useSpeakingParticipants`
* поверх `RoomEvent.ActiveSpeakersChanged`) сам по себе не дребезжит, но
@@ -59,6 +62,10 @@ const STAGE_TRACK_SOURCES = [
* фокус с задержкой, которая на глаз читается как плавность, а не как тормоз.
* Меньше (~0.6 с) — короткие «ага» всё ещё пролезают, больше (~2 с) — заметно
* запаздывает переход на нового докладчика.
*
* В мини-плеере удержание тем более уместно: там плитка ОДНА, и мгновенное
* переключение читается не как «камера следует за разговором», а как мигание
* всего окна целиком.
*/
const SPEAKER_HOLD_MS = 1200
@@ -69,8 +76,9 @@ const SPEAKER_HOLD_MS = 1200
* применять уже нечего (cleanup эффекта гасит таймер, а новое значение
* сравнивается по ссылке с текущим).
*
* `holdMs <= 0` — удержания нет, значение отдаётся как есть (режим PiP: там
* фокус обязан следовать за говорящим мгновенно, поведение не менялось).
* `holdMs <= 0` — удержания нет, значение отдаётся как есть. Сейчас этим
* режимом никто не пользуется (обе сцены удерживают состав), но параметр
* оставлен: он и делает функцию пригодной для повторного использования.
*/
function useSteadySpeakers(speakers: Participant[], holdMs: number): Participant[] {
const [steady, setSteady] = useState(speakers)
@@ -109,7 +117,7 @@ function PipMicToggle() {
<button
type="button"
{...mic.buttonProps}
className={`room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
className={`room-pip-btn room-pip-mic-toggle${mic.enabled ? '' : ' is-off'}`}
aria-label={mic.enabled ? 'Выключить микрофон' : 'Включить микрофон'}
>
{mic.enabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
@@ -117,6 +125,59 @@ function PipMicToggle() {
)
}
/**
* Кнопка демонстрации экрана в мини-плеере — сестра `PipMicToggle` (0.0.29).
* Тот же `useTrackToggle` и те же `SCREEN_SHARE_CAPTURE_OPTIONS`, что у кнопки
* основного тулбара (`RoomToolbar`), поэтому обе кнопки — два вида одного
* состояния: включённая из мини-окна демонстрация показывает основную кнопку
* активной и наоборот, рассинхрону взяться неоткуда (состояние читается из
* `RoomContext`, а не из DOM).
*
* 🔬 ПОЧЕМУ ЭТО ВООБЩЕ РАБОТАЕТ ИЗ ДРУГОГО ОКНА. `getDisplayMedia()` требует
* транзиентной активации пользователя, а клик здесь происходит в PiP-окне,
* тогда как сам код (React-дерево целиком остаётся в основном документе,
* порталом уезжает только DOM) вызывает `navigator` ОСНОВНОГО окна. Замерено
* в Chrome 150 отдельной пробой: после клика в Document PiP
* `navigator.userActivation.isActive === true` в ОБОИХ окнах — активация
* доезжает до опенера, вызов проходит, системный пикер выбора экрана
* открывается отдельным окном поверх остальных (а не прячется за заглушкой
* «Конференция открыта в отдельном мини-окне» в основном окне). Проверять это
* пришлось живьём: спецификация такого поведения не обещает, и на других
* движках оно может отличаться — но Document PiP есть только в Chrome/Edge,
* так что других движков здесь и не бывает (в Safari мини-окно — нативный
* video-PiP без собственного DOM, кнопке там просто негде жить, в Firefox
* мини-окна нет вовсе).
*
* Ошибку показываем тостом основного окна: пользователь его сейчас не видит
* (там заглушка), но увидит, как только вернётся, — а единственный частый
* «сбой», отмена пикера, и так молча игнорируется, как в основном тулбаре.
*/
function PipScreenShareToggle() {
const toast = useToast()
const screenShare = useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: SCREEN_SHARE_CAPTURE_OPTIONS,
onDeviceError: (error) => {
if (error.name === 'NotAllowedError') return
toast.show('Не удалось начать демонстрацию экрана', 'error')
},
})
return (
<button
type="button"
{...screenShare.buttonProps}
className={`room-pip-btn room-pip-share-toggle${screenShare.enabled ? ' is-on' : ''}`}
aria-label={screenShare.enabled ? 'Остановить демонстрацию экрана' : 'Демонстрация экрана'}
>
{screenShare.enabled ? (
<ScreenShareOff className="lucide" aria-hidden="true" />
) : (
<ScreenShare className="lucide" aria-hidden="true" />
)}
</button>
)
}
/**
* Основная сцена конференции: превью остальных участников + крупная плитка
* активного спикера (FocusLayoutContainer + CarouselLayout при нескольких
@@ -148,9 +209,9 @@ function PipMicToggle() {
* `tiles` скрывать нечего (карусели нет), переключатель там заблокирован —
* см. `StageViewOptions`.
*
* ФОКУС ПЕРЕЖИВАЕТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере — ОТДЕЛЬНЫЙ
* экземпляр этого компонента (портал в PiP-окно), и своё состояние фокуса он
* начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* ФОКУС И ЗАКРЕПЛЕНИЕ ПЕРЕЖИВАЮТ ПЕРЕЕЗД В МИНИ-ПЛЕЕР. Сцена в мини-плеере —
* ОТДЕЛЬНЫЙ экземпляр этого компонента (портал в PiP-окно), и своё состояние
* фокуса он начинал с нуля: демонстрации нет, никто прямо сейчас не говорит — и
* `pickStageFocus` доходил до последнего фолбэка `localKey`, то есть мини-окно
* открывалось на самом пользователе вместо того, что он видел крупно. В Safari
* бага не было видно: там Document PiP не используется, а video-фолбэк
@@ -159,6 +220,13 @@ function PipMicToggle() {
* `RoomPage` → `initialFocusKey` следующего экземпляра. Работает в обе стороны
* — возврат из мини-плеера тоже не сбрасывает фокус.
*
* Ровно тем же мостиком с 0.0.25 ездит и ЗАКРЕПЛЕНИЕ (`initialPinnedKey` /
* `onPinnedKeyChange`): раньше `pinnedKey` был чисто локальным `useState`, и
* закрепление, сделанное в основном окне, в мини-плеер не попадало вовсе.
* Отдельный «общий» источник правды здесь не нужен: экземпляр сцены в каждый
* момент ровно один (пока открыт Document PiP, основное окно показывает
* заглушку — см. `RoomPage`), поэтому состояние достаточно передать по эстафете.
*
* Раскладка — вертикальная колонка миниатюр слева от основной сцены (не
* горизонтальная лента, см. design/mockups/room.html после правки: узкая
* колонка сбоку, скролл по вертикали). Это штатное поведение самого
@@ -188,12 +256,21 @@ function PipMicToggle() {
* показываем ТОЛЬКО одну крупную плитку активного окна — без карусели/грида;
* режимы показа и скрытие остальных на мини-плеер не влияют вовсе.
*
* Фокус следует за активным спикером в ОБОИХ вариантах (`followSpeaker` у
* `pickStageFocus`; для основного окна — с 0.0.6, задача 3.2), но по-разному:
* PiP переключается мгновенно и всегда показывает говорящего, а основное окно
* ждёт `SPEAKER_HOLD_MS` (не дёргается на коротких репликах), не уводит из
* фокуса живую демонстрацию экрана (`holdScreenShare`) и умеет закрепление
* участника (`pinnedKey`, задача 3.1) — кнопка-булавка на плитке.
* ВЫБОР ФОКУСА ОДИНАКОВ В ОБОИХ ВАРИАНТАХ (с 0.0.25). До этого мини-плеер был
* намеренно «упрощён»: без удержания говорящего, без удержания демонстрации
* экрана (`holdScreenShare`), без приоритета говорящего с включённой камерой и
* без закрепления. На практике это читалось как поломка: в мини-окне
* демонстрация экрана слетала от любой чужой реплики, а закрепление,
* сделанное в основном окне, не действовало. Теперь `pickStageFocus`
* получает одни и те же правила независимо от варианта — разным остаётся
* ровно одно: `localKey` (см. ниже) и то, что PiP рисует одну плитку вместо
* раскладки.
*
* Единственное сознательное отличие — `localKey`: у мини-плеера есть
* последний фолбэк «показать себя», у основного окна его нет (там фолбэк —
* первый трек по порядку, поведение не менялось). Строка из того же сюжета,
* что и `initialFocusKey`: без неё свежеоткрытое мини-окно на пустой комнате
* выбирало произвольного участника.
*/
export function RoomStage({
variant = 'full',
@@ -203,6 +280,8 @@ export function RoomStage({
onHideOthers,
initialFocusKey = null,
onFocusKeyChange,
initialPinnedKey = null,
onPinnedKeyChange,
onPinFocus,
raisedHandIdentities,
conferenceId,
@@ -221,6 +300,10 @@ export function RoomStage({
initialFocusKey?: string | null
/** Сообщать наружу текущий фокус, чтобы его пережил переезд сцены в мини-плеер и обратно. */
onFocusKeyChange?: (key: string | null) => void
/** Чем инициализировать закрепление при монтировании — тот же мостик через `RoomPage`, что и у фокуса. */
initialPinnedKey?: string | null
/** Сообщать наружу закрепление, чтобы оно пережило переезд сцены в мини-плеер и обратно. */
onPinnedKeyChange?: (key: string | null) => void
/**
* Участника только что закрепили (не открепили) в режиме без крупной
* плитки — сцена сама переключиться не может (режим живёт в `RoomPage`),
@@ -245,9 +328,9 @@ export function RoomStage({
// (`Room.activeSpeakers`, обновляются по `RoomEvent.ActiveSpeakersChanged`,
// событие шлётся лишь при РЕАЛЬНОЙ смене состава/порядка говорящих — не
// дребезжит на каждый чих, в отличие от сырого `participant.isSpeaking`).
// Основное окно поверх этого ещё и удерживает состав (см. `useSteadySpeakers`
// и `SPEAKER_HOLD_MS`), PiP берёт значение как есть.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), variant === 'pip' ? 0 : SPEAKER_HOLD_MS)
// Поверх этого сцена ещё и удерживает состав (см. `useSteadySpeakers` и
// `SPEAKER_HOLD_MS`) — в обоих вариантах одинаково.
const speakingParticipants = useSteadySpeakers(useSpeakingParticipants(), SPEAKER_HOLD_MS)
const cameraTracks = tracks.filter((t) => t.source === Track.Source.Camera)
const screenShareTracks = tracks.filter((t) => isTrackReference(t) && t.source === Track.Source.ScreenShare)
@@ -289,8 +372,10 @@ export function RoomStage({
const [focusKey, setFocusKey] = useState<string | null>(initialFocusKey)
// Закрепление живёт в состоянии сцены (задача 3.1): ключ `identity:source`
// плитки, которую пользователь закрепил булавкой; `null` — закрепления нет.
// Только для основного окна — в PiP плитка одна и закреплять нечего.
const [pinnedKey, setPinnedKey] = useState<string | null>(null)
// Стартовое значение приходит от предыдущего экземпляра сцены (тот же
// мостик через `RoomPage`, что и у фокуса), поэтому закрепление, сделанное
// в основном окне, действует и в мини-плеере.
const [pinnedKey, setPinnedKey] = useState<string | null>(initialPinnedKey)
const [prevPinnedKey, setPrevPinnedKey] = useState<string | null>(null)
const cameraKeys = cameraTracks.map(stageTrackKey)
@@ -299,13 +384,23 @@ export function RoomStage({
// камера есть у КАЖДОГО участника хотя бы плейсхолдером, — ни среди
// демонстраций) — закрепление снимаем, чтобы сцена не осталась в подвешенном
// состоянии и булавка не «висела» на исчезнувшем ключе.
//
// `tracksKnown` — обязательная охрана, а не перестраховка: на ПЕРВОМ рендере
// нового экземпляра сцены `useTracks` отдаёт ПУСТОЙ массив (реальный состав
// приезжает следующим рендером, из подписки на события комнаты). Без этой
// проверки пустой набор читается как «все вышли», и закрепление, приехавшее
// через `initialPinnedKey`, обнулялось сразу при монтировании — то есть
// мини-плеер терял его каждый раз (найдено живой отладкой при 0.0.25).
// Пустых наборов при живой комнате не бывает: камера есть у каждого
// участника хотя бы плейсхолдером.
const tracksKnown = cameraKeys.length > 0 || screenShareKeys.length > 0
const pinnedAlive = pinnedKey !== null && (cameraKeys.includes(pinnedKey) || screenShareKeys.includes(pinnedKey))
const tracksChanged = tracks !== prevTracks
const speakingChanged = speakingParticipants !== prevSpeakingParticipants
const pinnedChanged = pinnedKey !== prevPinnedKey
if (pinnedKey !== null && !pinnedAlive) {
if (pinnedKey !== null && tracksKnown && !pinnedAlive) {
setPinnedKey(null)
}
@@ -328,14 +423,12 @@ export function RoomStage({
cameraKeys,
screenShareKeys,
speakingCameraKeys,
// Приоритет «говорящий с камерой выше говорящего без камеры» — только
// основному окну: PiP по договорённости ведёт себя ровно как раньше.
cameraKeysWithVideo: variant === 'pip' ? [] : cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
cameraKeysWithVideo: cameraTracks.filter(hasLiveVideo).map(stageTrackKey),
prevKeys,
prevFocusKey: focusKey,
pinnedKey: pinnedAlive ? pinnedKey : null,
followSpeaker: true,
holdScreenShare: variant !== 'pip',
holdScreenShare: true,
// Только для PiP — в основном окне фолбэк на «первый трек» не менялся.
localKey: variant === 'pip' ? `${room.localParticipant.identity}:${Track.Source.Camera}` : null,
})
@@ -352,6 +445,11 @@ export function RoomStage({
onFocusKeyChange?.(focusKey)
}, [focusKey, onFocusKeyChange])
// То же самое для закрепления — см. `initialPinnedKey`.
useEffect(() => {
onPinnedKeyChange?.(pinnedKey)
}, [pinnedKey, onPinnedKeyChange])
const focusTrack = tracks.find((t) => stageTrackKey(t) === focusKey) ?? screenShareTracks[0] ?? cameraTracks[0]
const focusTrackKey = focusTrack ? stageTrackKey(focusTrack) : null
// При активной демонстрации карусель — ВСЕ камеры (включая демонстратора) И
@@ -394,13 +492,30 @@ export function RoomStage({
// Мини-плеер показывает ТОЛЬКО активное окно — без карусели/
// грида, одна плитка на весь контейнер (см. `.room-single-tile`,
// `styles/room.css`). `focusTrack` уже вычислен выше тем же `pickStageFocus`
// (с `followSpeaker: true` для этого варианта) — переиспользуем как есть.
// и по тем же правилам, что и в основном окне (закрепление, удержание
// демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`. А вот микрофон и демонстрация экрана в мини-окне есть:
// это не «вид», а действия, которые нужны прямо посреди разговора, и ради них
// разворачивать основное окно (то есть закрывать мини-окно) бессмысленно.
//
// СОБСТВЕННАЯ ДЕМОНСТРАЦИЯ в мини-окне ПОКАЗЫВАЕТСЯ — по тем же правилам
// `pickStageFocus`, что и в основном окне (last-wins на старте, возврат на
// говорящего/закреплённого после остановки). Решение оператора при приёмке
// 0.0.29: демонстратор должен видеть, что именно он демонстрирует, ровно то
// же, что видят остальные. Пробная версия прятала свой шэр из PiP (чтобы
// вместо него была видна аудитория и чтобы при выборе «весь экран» не
// получался зеркальный туннель «мини-окно внутри мини-окна») — отклонена.
// Туннель при выборе «весь экран» остаётся известным и принятым поведением.
if (variant === 'pip') {
return (
<section className="stage room-single-tile">
{focusTrack && <RoomParticipantTile trackRef={focusTrack} onStopSharing={handleStopSharing} />}
<div className="room-pip-controls">
<PipMicToggle />
<RoomAudioRenderer />
<PipScreenShareToggle />
</div>
</section>
)
}
@@ -496,7 +611,6 @@ export function RoomStage({
<span>Показать остальных ({sideTracks.length})</span>
</button>
)}
<RoomAudioRenderer />
</section>
)
}

View File

@@ -1,5 +1,6 @@
import {
Hand,
Image,
LogOut,
Maximize,
MessageSquare,
@@ -13,33 +14,14 @@ import {
Video,
VideoOff,
} from 'lucide-react'
import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { Track } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран». Вынесено в
* модульную константу — `useTrackToggle` держит `JSON.stringify(captureOptions)`
* в зависимостях внутреннего `useMemo`, инлайновый литерал был бы безвреден,
* но константа явнее фиксирует неизменность опций.
*/
const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}
import { SCREEN_SHARE_CAPTURE_OPTIONS } from '@/lib/screenShareOptions'
interface RoomToolbarProps extends StageViewProps {
/** Показывать ли кнопку чата — `JoinOut.chat_enabled` И чат не помечен недоступным (close-код 4404). */
@@ -58,6 +40,17 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean
pipActive: boolean
onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean
/**
* Показывать ли кнопку «Фон». Уже учитывает ВСЁ сразу: включён ли модуль в
* админке, десктопное ли это устройство и умеет ли браузер сегментацию —
* см. `RoomPage`. При `false` кнопки нет вовсе (а не задизейбленной): на
* телефоне фичи не существует, и мёртвая кнопка там только мешает.
*/
backgroundVisible: boolean
backgroundOpen: boolean
onToggleBackground: () => void
/**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -68,6 +61,16 @@ interface RoomToolbarProps extends StageViewProps {
onLowerHand: () => void
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
onLowerHandById: (identity: string) => void
/** Тулбар в оверлее полноэкранного режима — см. докстринг `RoomTopbar.overlayVisible`, тот же механизм. */
overlayVisible?: boolean
/**
* Пользователь нажал «Выйти» — вызывается ПЕРЕД тем, как `DisconnectButton`
* отключит комнату (обработчики в `mergeProps` вызываются цепочкой). Нужен
* `RoomPage`, чтобы отличить намеренный выход от разрыва: причина
* `CLIENT_INITIATED` приходит и от кнопки, и от livekit-client при заморозке
* вкладки — см. докстринг `handleDisconnected`.
*/
onLeave?: () => void
}
/**
@@ -77,16 +80,22 @@ interface RoomToolbarProps extends StageViewProps {
* панели чата, стилизованные по design/mockups/room.html.
*
* Кнопка «Вид» (режимы показа и скрытие остальных) рендерится ТОЛЬКО на
* широком экране — условным рендерингом, а не скрытием через CSS: тулбар на
* мобильном и так ужат до пяти «безусловных» кнопок (демонстрация/
* полноэкранный режим/мини-плеер скрыты на узком экране через CSS, см.
* `styles/room.css`), а те же настройки там доступны секцией «Вид» в шторке
* настроек (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* широком экране — условным рендерингом, а не скрытием через CSS: демонстрация
* и мини-плеер скрыты на узком экране через CSS (см. `styles/room.css`), а те
* же настройки показа сцены доступны секцией «Вид» в шторке настроек
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
* недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* «28-hand-queue-for-all»: раньше только организатору), по той же причине
* оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
* ряд, `mobileButtonCount`/`.tb-wrap-grid` ниже раскладывают их равномерной
* сеткой, а не как получится через `flex-wrap`.
*/
export function RoomToolbar({
chatVisible,
@@ -100,6 +109,10 @@ export function RoomToolbar({
pipSupported,
pipActive,
onTogglePiP,
handQueueEnabled,
backgroundVisible,
backgroundOpen,
onToggleBackground,
handQueue,
onRaiseHand,
onLowerHand,
@@ -108,11 +121,34 @@ export function RoomToolbar({
onLayoutModeChange,
hideOthers,
onHideOthersChange,
overlayVisible,
onLeave,
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных
// кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога
// зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на
// 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки
// (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
// `handQueueEnabled`, не от роли.
const mobileButtonCount =
2 /* микрофон, камера */ +
(handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ +
// На телефоне всегда 0 (фича десктопная), но десктопное окно бывает и уже
// 600px — тогда кнопка «Фон» реально есть и должна попасть в подсчёт.
(backgroundVisible ? 1 : 0) +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
const mic = useTrackToggle({ source: Track.Source.Microphone })
const camera = useTrackToggle({ source: Track.Source.Camera })
const screenShare = useTrackToggle({
@@ -129,7 +165,9 @@ export function RoomToolbar({
})
return (
<footer className="room-toolbar">
<footer
className={`room-toolbar${overlayVisible ? ' is-visible' : ''}${mobileButtonCount >= 7 ? ' tb-wrap-grid' : ''}`}
>
<button
type="button"
{...mic.buttonProps}
@@ -178,6 +216,7 @@ export function RoomToolbar({
<span className="label">Демонстрация</span>
</button>
{handQueueEnabled && (
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
@@ -193,8 +232,9 @@ export function RoomToolbar({
</span>
<span className="label">Рука</span>
</button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
{handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
{!isCompact && (
<StageViewMenu
@@ -214,9 +254,29 @@ export function RoomToolbar({
<span className="icon-shell">
<Settings className="lucide" aria-hidden="true" />
</span>
<span className="label">Устройства</span>
{/* На мобильном подпись шире по смыслу («Настройки»): там же в шторке
секция «Вид», а отдельной кнопки под неё в тулбаре нет. На десктопе
панель — только настройки устройств, подпись это отражает. */}
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button>
{/* Только десктоп — на мобильном `backgroundVisible` всегда false,
поэтому кнопка не участвует и в `mobileButtonCount` выше. */}
{backgroundVisible && (
<button
type="button"
className={`tb-btn tb-btn--background${backgroundOpen ? ' is-panel-open' : ''}`}
aria-label={backgroundOpen ? 'Закрыть выбор фона' : 'Выбрать фон'}
aria-pressed={backgroundOpen}
onClick={onToggleBackground}
>
<span className="icon-shell">
<Image className="lucide" aria-hidden="true" />
</span>
<span className="label">Фон</span>
</button>
)}
{fullscreenSupported && (
<button
type="button"
@@ -276,7 +336,7 @@ export function RoomToolbar({
</button>
)}
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции">
<DisconnectButton className="tb-btn danger" aria-label="Выйти из конференции" onClick={onLeave}>
<span className="icon-shell">
<LogOut className="lucide" aria-hidden="true" />
</span>

View File

@@ -15,6 +15,12 @@ interface RoomTopbarProps {
/** Slug/номер конференции из адреса — для инвайт-чипа (копирование ссылки). */
slug?: string
number?: string
/**
* Полноэкранный режим (`RoomPage.tsx`) прячет топбар в оверлей и показывает
* его только по этому флагу — вне полноэкранного режима не влияет ни на что
* (CSS-правило само по себе действует лишь под `.room-fullscreen-overlay`).
*/
overlayVisible?: boolean
}
/**
@@ -26,7 +32,7 @@ interface RoomTopbarProps {
* тёмных токенов темы `room` (см. `--color-room-tile*`), без новых
* цветов и форм.
*/
export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
export function RoomTopbar({ title, slug, number, overlayVisible }: RoomTopbarProps) {
const participants = useParticipants()
const [copied, setCopied] = useState(false)
@@ -44,7 +50,7 @@ export function RoomTopbar({ title, slug, number }: RoomTopbarProps) {
}
return (
<header className="room-topbar">
<header className={`room-topbar${overlayVisible ? ' is-visible' : ''}`}>
<div className="room-title-block">
<h1>{title ?? 'Конференция без названия'}</h1>
<p>

View File

@@ -0,0 +1,46 @@
import { useMyBackgrounds } from '@/hooks/useMyBackgrounds'
import { useVirtualBackground } from '@/hooks/useVirtualBackground'
import { BackgroundDialog } from '@/components/room/BackgroundDialog'
interface VirtualBackgroundControllerProps {
/** Открыта ли модалка выбора; сам фон применяется независимо от этого. */
open: boolean
onClose: () => void
/** Аутентифицированный участник — только у него есть свои картинки (гость их иметь не может). */
canManageOwn: boolean
}
/**
* Живёт ВНУТРИ `<LiveKitRoom>` и держит замену фона включённой всё время
* пребывания в комнате.
*
* Почему это отдельный компонент, а не хук прямо в `RoomPage`:
* `useVirtualBackground` читает локального участника через `useLocalParticipant`,
* а тот берёт комнату из `RoomContext` — контекст создаётся самим
* `<LiveKitRoom>`, и в теле `RoomPage` (которое этот элемент только
* возвращает) его ещё нет.
*
* Компонент смонтирован ВСЁ время, пока модуль включён, а не только пока
* открыта модалка: фон должен переживать закрытие окна выбора, выключение и
* повторное включение камеры и смену устройства (см. докстринг хука).
*/
export function VirtualBackgroundController({
open,
onClose,
canManageOwn,
}: VirtualBackgroundControllerProps) {
const { items } = useMyBackgrounds(canManageOwn)
const { backgroundKey, selectBackground, status } = useVirtualBackground(true, items)
if (!open) return null
return (
<BackgroundDialog
onClose={onClose}
value={backgroundKey}
onChange={selectBackground}
customBackgrounds={items}
status={status}
canManageOwn={canManageOwn}
/>
)
}

View File

@@ -62,8 +62,10 @@ export interface PickStageFocusInput {
/** Ключ, что был в фокусе на предыдущем рендере; `null` — фокус ещё не выбирался. */
prevFocusKey: string | null
/**
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем в основном окне (кнопка-булавка на
* плитке, состояние живёт в `RoomStage.tsx`); `null` — закрепления нет.
* Ключ трека, ЗАКРЕПЛЁННОГО пользователем (кнопка-булавка на плитке
* основного окна; состояние живёт в `RoomStage.tsx` и переезжает в
* мини-плеер через `RoomPage`, см. там `initialPinnedKey`); `null` —
* закрепления нет.
* Закрепление держит фокус вопреки говорящим, но уступает ЛЮБОЙ активной
* демонстрации экрана (формулировка оператора: «перебивается только чьей-либо
* демонстрацией экрана») — а когда демонстрация закончилась, фокус
@@ -79,19 +81,22 @@ export interface PickStageFocusInput {
* не удерживать текущий). С 0.0.6 включено и для мини-плеера (PiP), и для
* основного окна — решение оператора (этап 3, задача 3.2). Защита от
* дребезга — на стороне вызывающего: источник «говорящих» — throttled
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged`, а в
* основном окне ещё и удержание в ~1.2 с (см. `useSteadySpeakers` в
* `RoomStage.tsx`), не сырой дребезжащий `participant.isSpeaking`.
* `useSpeakingParticipants()` поверх `RoomEvent.ActiveSpeakersChanged` плюс
* удержание в ~1.2 с (см. `useSteadySpeakers` в `RoomStage.tsx`, с 0.0.25 —
* в обеих сценах), не сырой дребезжащий `participant.isSpeaking`.
* По умолчанию `false` — фокус удерживается (см. правило 5).
*/
followSpeaker?: boolean
/**
* Живая демонстрация экрана в фокусе НЕ уступает заговорившему участнику
* (правило 3). Нужно основному окну: там демонстрация — это содержательный
* центр разговора, и уводить её из большого окна на каждую реплику нельзя.
* Мини-плеер (PiP) показывает ровно одну плитку и намеренно ведёт себя иначе
* — всегда показывает того, кто говорит, поэтому там `false` (поведение
* PiP не менялось с 0.0.4).
* (правило 3). Демонстрация — это содержательный центр разговора, и уводить
* её из фокуса на каждую реплику нельзя.
*
* С 0.0.25 включено в ОБЕИХ сценах. До этого мини-плеер (PiP) намеренно
* ходил с `false` — «одна плитка, всегда показываем говорящего»; на практике
* это выглядело как поломка: демонстрация в мини-окне пропадала, стоило
* кому-то сказать слово. По умолчанию всё ещё `false` — это поведение
* функции без явного запроса удержания.
*/
holdScreenShare?: boolean
/**
@@ -132,16 +137,14 @@ function pickSpeakerKey(
* фокус безусловно переходит на него (последний из новых, если появилось
* сразу несколько), даже если до этого в фокусе была камера или другая
* демонстрация. Так же ведут себя типовые UI конференций (Google Meet).
* 2. Закрепление (`pinnedKey`, только основное окно): закреплённый участник
* 2. Закрепление (`pinnedKey`): закреплённый участник
* забирает фокус у говорящих и у удержания предыдущего фокуса, но уступает
* ЛЮБОЙ активной демонстрации экрана. Поэтому правило и стоит выше
* удержания (правило 5): как только демонстрация закончилась и
* `screenShareKeys` опустел, фокус возвращается на закреплённого, а не
* остаётся на том, кто был в фокусе до демонстрации.
* 3. `holdScreenShare` (только основное окно): демонстрация, уже стоящая в
* фокусе, не уступает заговорившему — иначе большое окно уводило бы шэр на
* каждую реплику. В PiP шаг пропускается (там одна плитка и она всегда
* показывает говорящего).
* 3. `holdScreenShare`: демонстрация, уже стоящая в фокусе, не уступает
* заговорившему — иначе окно уводило бы шэр на каждую реплику.
* 4. `followSpeaker`: если сейчас есть говорящий — фокус СРАЗУ переходит на
* него, даже если текущий фокус ещё жив; среди одновременно говорящих
* предпочитаем того, у кого включена камера (`cameraKeysWithVideo`).

View File

@@ -0,0 +1,413 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import type { BackgroundProcessorWrapper } from '@livekit/track-processors'
import { createBackgroundProcessor, startProcessorOnTrack } from '@/lib/virtualBackground'
export type DeviceCheckStatus = 'idle' | 'pending' | 'granted' | 'denied'
interface UseDeviceCheckAccessResult {
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** Привязать к `ref` `<video>` превью — коллбэк, НЕ объект-реф: карточка
* превью рендерится в разных местах JSX-дерева на разных шагах
* (`input`/`guest-info` в `JoinPage`), и React монтирует для каждого места
* СВОЙ DOM-узел `<video>`, хотя тип компонента один и тот же — обычный
* `ref.current` продолжал бы указывать на старый (уже отмонтированный)
* узел. Коллбэк вызывается при каждом монтировании нового узла и сам
* подключает уже открытый поток (см. докстринг хука) — без этого переход
* между шагами давал бы на месте превью чёрный прямоугольник: поток жив,
* но не подключён к новому элементу. */
videoRef: (node: HTMLVideoElement | null) => void
/** Повесить на карточку (`onPointerDown`/`onKeyDown`) — первое взаимодействие
* внутри неё запускает запрос доступа. Идемпотентно, повторные вызовы —
* no-op (см. `requestedRef`). */
triggerOnGesture: () => void
/** Понятная подсказка про отказ в доступе — `null`, пока запроса не было
* или обе камера/микрофон доступны. Отказ НЕ блокирует форму — вызывающая
* карточка просто показывает текст рядом и позволяет идти дальше. */
hint: string | null
/** Полный сброс: остановить треки камеры и вернуть весь стейт к исходному
* (микрофон отдельного потока не держит, см. `requestAudio`) — звать при
* уходе с карточки, сабмите формы и любой ошибке. Безопасно вызывать
* многократно. */
release: () => void
/**
* «Войти с включённым микрофоном/камерой» — И предпочтение пользователя
* для будущего входа, И (только для видео) реальное состояние превью:
* кнопка камеры действительно останавливает/перезапускает поток (индикатор
* камеры гаснет), а не просто прячет `<video>` поверх работающего потока —
* иначе кнопка «выключить камеру» на этом самом экране обходила бы весь
* смысл фичи. Микрофон отдельного живого потока не держит (см. `requestAudio`),
* поэтому `audioEnabled` — чистый флаг предпочтения, переключается мгновенно.
* По умолчанию `false` — сохраняет поведение существующих инсталляций для
* всех, кто кнопки не трогал. Сбрасывается в `release()` — при уходе с
* карточки следующий заход в неё (напр. «Назад» → снова резолвить) должен
* начинать с чистого состояния, а не с потухшего превью и protection против
* повторного запроса.
*/
videoEnabled: boolean
audioEnabled: boolean
/** Переключить камеру — реально останавливает/перезапускает поток (см. выше). */
toggleVideoEnabled: () => void
toggleAudioEnabled: () => void
/**
* Выбранный фон применить не удалось — превью показывает «сырую» камеру.
* Нужен, чтобы сбой не был МОЛЧАЛИВЫМ: без этого флага пользователь видит
* выбранную плитку с галочкой и обычную картинку и решает, что фон просто
* не работает (ровно на это наступили на приёмке 0.0.35).
*/
backgroundFailed: boolean
}
/**
* Доступ к камере/микрофону на входе (сессия 33, `LoginPage`/`JoinPage`) —
* общая логика для обеих публичных карточек, чтобы не дублировать её между
* гостевым и обычным флоу входа (см. промпт сессии).
*
* Решения и почему:
* - **Камера и микрофон запрашиваются НЕЗАВИСИМО** (`requestVideo`, затем
* `requestAudio`, отдельные вызовы `getUserMedia`) — совместный вызов
* `getUserMedia({audio:true, video:true})` падает целиком при отказе в
* ЛЮБОМ из разрешений, а превью камеры и отдельная подсказка про микрофон
* должны работать даже если пользователь разрешил только одно из двух.
* - **Микрофон никогда не остаётся активным** — превью только у камеры
* («только превью» из задачи), поток микрофона останавливается сразу
* после получения разрешения (см. `requestAudio`), сам факт разрешения
* при этом остаётся выданным браузером — заново спрашивать не будет.
* - **Поток камеры живёт, пока карточка открыта** — останавливается явно
* через `release()` (сабмит/уход/размонтирование/ошибка вызывающей
* стороны) и автоматически при размонтировании самого хука.
* - **Автозапуск без жеста, если разрешение уже выдано** (`navigator.permissions`,
* где поддерживается) — тогда `getUserMedia` не покажет системный диалог
* вообще, и ждать клика незачем; иначе (в т.ч. Safari без Permissions API
* для камеры/микрофона) — только по жесту `triggerOnGesture`, иначе Safari
* и мобильные браузеры молча отклоняют вызов при простой загрузке страницы.
* - **Устройство по умолчанию — сохранённый выбор пользователя**
* (`usePersistentUserChoices`, тот же ключ localStorage, что и в комнате,
* см. `RoomPage.tsx`); если сохранённого ID больше не существует
* (`OverconstrainedError`) — фолбэк на устройство по умолчанию системы.
*/
export function useDeviceCheckAccess(
enabled: boolean,
backgroundUrl: string | null = null,
): UseDeviceCheckAccessResult {
const { userChoices } = usePersistentUserChoices()
// Не в зависимостях эффектов/колбэков ниже — коллбэки живут в event-хендлерах
// (жест), а не в реактивном дереве; актуальное значение достаточно иметь на
// момент фактического вызова, ref обновляется отдельным эффектом.
const userChoicesRef = useRef(userChoices)
useEffect(() => {
userChoicesRef.current = userChoices
}, [userChoices])
const [videoStatus, setVideoStatus] = useState<DeviceCheckStatus>('idle')
const [audioStatus, setAudioStatus] = useState<DeviceCheckStatus>('idle')
const [videoEnabled, setVideoEnabled] = useState(false)
const [audioEnabled, setAudioEnabled] = useState(false)
const toggleAudioEnabled = useCallback(() => setAudioEnabled((v) => !v), [])
const videoStreamRef = useRef<MediaStream | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
// Что реально показывается в `<video>`: либо сам поток камеры, либо поток с
// наложенным фоном. Отдельно от `videoStreamRef` — тот всегда остаётся
// «сырым» источником, который надо остановить при освобождении камеры
// (обработанный трек камеру не держит и сам её не выключит).
const displayStreamRef = useRef<MediaStream | null>(null)
// См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает
// уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого
// переход между шагами с превью терял бы картинку (но не поток — камера
// продолжала бы физически работать, просто без видимого превью).
const videoRef = useCallback((node: HTMLVideoElement | null) => {
videoNodeRef.current = node
if (node) {
node.srcObject = displayStreamRef.current ?? videoStreamRef.current
}
}, [])
const requestedRef = useRef(false)
// --- Замена фона в превью (сессия 35) ---------------------------------
// Процессор сегментации, живущий поверх «сырого» трека камеры, и трек, на
// который он навешен (по нему видно, что источник сменился и процессор надо
// пересоздать: выключение/включение камеры выдаёт НОВЫЙ трек).
const processorRef = useRef<BackgroundProcessorWrapper | null>(null)
const processedSourceRef = useRef<MediaStreamTrack | null>(null)
// Служебный `<video>` с ИСХОДНЫМ потоком, из которого процессор читает
// кадры (см. `startProcessorOnTrack`) — в DOM не попадает, но отпускать его
// надо явно, иначе он продолжит крутить поток после уничтожения процессора.
const processorElementRef = useRef<HTMLVideoElement | null>(null)
// Все операции с процессором строго последовательны: они асинхронны и
// небыстры (первый раз — ещё и скачивание модели), а щёлкать по фонам можно
// сколько угодно быстро.
const chainRef = useRef<Promise<void>>(Promise.resolve())
// Счётчик смен «сырого» потока — по нему эффект синхронизации понимает, что
// источник изменился. Отдельное число, а не сам поток в зависимостях:
// MediaStream не участвует в реактивном стейте, реф React не отслеживает.
const [videoSourceVersion, setVideoSourceVersion] = useState(0)
const [backgroundFailed, setBackgroundFailed] = useState(false)
const showStream = useCallback((stream: MediaStream | null) => {
displayStreamRef.current = stream
if (videoNodeRef.current) {
videoNodeRef.current.srcObject = stream
}
}, [])
const destroyProcessor = useCallback(() => {
const processor = processorRef.current
processorRef.current = null
processedSourceRef.current = null
const element = processorElementRef.current
processorElementRef.current = null
if (element) {
element.pause()
element.srcObject = null
}
// Освобождение асинхронное, но ждать его некому и незачем: вызывающая
// сторона уже перешла к показу «сырого» потока либо гасит камеру.
if (processor) void processor.destroy()
}, [])
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана
// повторного запроса. Нужен и на «настоящем» уходе (сабмит/размонтирование),
// и на возврате к этой же карточке В ПРЕДЕЛАХ одного монтирования хука
// (JoinPage не размонтирует компонент между шагами флоу — см. её докстринг):
// без сброса `requestedRef` повторный заход не переспросил бы доступ и
// навсегда остался бы с потухшим превью при formально «granted» статусе.
const release = useCallback(() => {
destroyProcessor()
const stream = videoStreamRef.current
if (stream) {
stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null
}
showStream(null)
requestedRef.current = false
setVideoStatus('idle')
setAudioStatus('idle')
setVideoEnabled(false)
setAudioEnabled(false)
setBackgroundFailed(false)
}, [destroyProcessor, showStream])
// Размонтирование карточки — последний рубеж освобождения камеры: даже
// если вызывающая сторона забудет свой release() на каком-то из путей
// выхода, эта отписка не даст камере остаться гореть.
useEffect(() => () => release(), [release])
const requestVideo = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setVideoStatus('denied')
return
}
setVideoStatus('pending')
try {
const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
videoStreamRef.current = stream
// Сначала показываем «сырой» поток — картинка появляется сразу, а фон
// (если выбран) наложится следом, когда доедет модель.
showStream(stream)
setVideoSourceVersion((version) => version + 1)
setVideoStatus('granted')
// Первичная верификация сразу показывает превью — «включено» по факту
// получения потока, а не отдельным действием пользователя.
setVideoEnabled(true)
} catch {
setVideoStatus('denied')
}
}, [showStream])
// Кнопка камеры реально управляет потоком — выключение останавливает
// треки (индикатор камеры гаснет, ровно то, ради чего вся фича), включение
// обратно — свежий `getUserMedia` (разрешение уже выдано, диалога не будет,
// локально занимает десятки мс). `videoStatus` при выключении остаётся
// `'granted'` намеренно: кнопка не должна блокироваться, доступ никуда не
// делся, остановлен только сам поток.
const toggleVideoEnabled = useCallback(() => {
if (videoEnabled) {
destroyProcessor()
const stream = videoStreamRef.current
if (stream) {
stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null
}
showStream(null)
setVideoSourceVersion((version) => version + 1)
setVideoEnabled(false)
return
}
void requestVideo()
}, [videoEnabled, requestVideo, destroyProcessor, showStream])
// Синхронизация фона превью с выбором пользователя и текущим источником.
//
// Здесь процессор навешивается НЕ на LiveKit-трек (его на этом экране ещё
// нет), а прямо на трек камеры: `ProcessorWrapper.init` принимает обычный
// `MediaStreamTrack` и отдаёт обработанный. В комнате тот же фон применяется
// уже к публикуемому треку (`useVirtualBackground`) — общий у них только
// сохранённый выбор (`lib/virtualBackground.ts`), пайплайны независимы.
//
// Сбой любого рода (нет поддержки, не доехала модель, трек умер по дороге)
// молча оставляет «сырое» превью: фон — украшение, а вход в конференцию
// ломать нельзя.
useEffect(() => {
const run = async () => {
const rawTrack = videoStreamRef.current?.getVideoTracks()[0] ?? null
const url = backgroundUrl
if (processorRef.current && processedSourceRef.current !== rawTrack) {
// Источник сменился (камеру выключили/включили) — прежний процессор
// сидел на прежнем треке и больше ни на что не годен.
destroyProcessor()
}
if (!rawTrack || !url) {
if (processorRef.current) destroyProcessor()
showStream(videoStreamRef.current)
setBackgroundFailed(false)
return
}
try {
if (processorRef.current) {
await processorRef.current.switchTo({ mode: 'virtual-background', imagePath: url })
return
}
const processor = await createBackgroundProcessor(url)
const element = await startProcessorOnTrack(processor, rawTrack)
// Пока грузилась модель, камеру могли выключить или сменить фон —
// навешивать процессор на исчезнувший источник уже некуда.
if (videoStreamRef.current?.getVideoTracks()[0] !== rawTrack) {
element.pause()
element.srcObject = null
await processor.destroy()
return
}
processorRef.current = processor
processedSourceRef.current = rawTrack
processorElementRef.current = element
if (processor.processedTrack) {
showStream(new MediaStream([processor.processedTrack]))
}
setBackgroundFailed(false)
} catch {
destroyProcessor()
showStream(videoStreamRef.current)
setBackgroundFailed(true)
}
}
chainRef.current = chainRef.current.then(run, run)
}, [backgroundUrl, videoSourceVersion, destroyProcessor, showStream])
const requestAudio = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setAudioStatus('denied')
return
}
setAudioStatus('pending')
try {
const stream = await openStream('audio', userChoicesRef.current.audioDeviceId)
// Только подтверждаем, что микрофон реально работает и разрешение
// получено, — держать поток открытым незачем, превью для него нет.
stream.getTracks().forEach((track) => track.stop())
setAudioStatus('granted')
} catch {
setAudioStatus('denied')
}
}, [])
const requestAccess = useCallback(async () => {
if (requestedRef.current) return
requestedRef.current = true
// Сначала видео — оно ценнее (превью), и лучше показать его как можно
// раньше; микрофон следом, отдельным системным диалогом.
await requestVideo()
await requestAudio()
}, [requestVideo, requestAudio])
// Автозапуск без ожидания жеста — только если браузер уже сообщает
// 'granted' по ОБОИМ разрешениям: тогда getUserMedia не покажет диалог,
// и ждать клика незачем. Permissions API для camera/microphone
// поддерживается не везде (Safari) — в catch/при отсутствии API просто
// остаёмся в режиме ожидания жеста, ничего не ломаем.
useEffect(() => {
if (!enabled) return
let cancelled = false
async function precheckAndMaybeAutoStart() {
const permissions = navigator.permissions
if (!permissions?.query) return
try {
const [camera, microphone] = await Promise.all([
permissions.query({ name: 'camera' as PermissionName }),
permissions.query({ name: 'microphone' as PermissionName }),
])
if (!cancelled && camera.state === 'granted' && microphone.state === 'granted') {
void requestAccess()
}
} catch {
// 'camera'/'microphone' — нестандартные имена Permissions API,
// часть браузеров (в т.ч. Safari) их не поддерживает вовсе.
}
}
void precheckAndMaybeAutoStart()
return () => {
cancelled = true
}
}, [enabled, requestAccess])
const triggerOnGesture = useCallback(() => {
if (!enabled) return
void requestAccess()
}, [enabled, requestAccess])
return {
videoStatus,
audioStatus,
videoRef,
triggerOnGesture,
hint: deviceCheckHint(videoStatus, audioStatus),
release,
videoEnabled,
audioEnabled,
toggleVideoEnabled,
toggleAudioEnabled,
backgroundFailed,
}
}
/** Открыть поток `kind` на сохранённом устройстве; если его больше нет —
* фолбэк на устройство по умолчанию системы (см. докстринг хука выше).
*
* `usePersistentUserChoices` хранит НЕвыбранное устройство не как пустую
* строку, а как литерал `"default"` (`@livekit/components-core`,
* `defaultUserChoices`) — это не реальный `deviceId`, и `{deviceId:{exact:
* "default"}}` для видео в Chrome падает `OverconstrainedError` на каждом
* первом визите (фолбэк ниже это лечит, но лишний неудачный проход того не
* стоит) — поэтому сентинел приравнивается к «выбора нет», как и пустая строка. */
async function openStream(kind: 'video' | 'audio', deviceId: string | undefined): Promise<MediaStream> {
const realDeviceId = deviceId && deviceId !== 'default' ? deviceId : undefined
const constraints: MediaStreamConstraints =
kind === 'video'
? { video: realDeviceId ? { deviceId: { exact: realDeviceId } } : true }
: { audio: realDeviceId ? { deviceId: { exact: realDeviceId } } : true }
try {
return await navigator.mediaDevices.getUserMedia(constraints)
} catch (err) {
if (realDeviceId && err instanceof DOMException && err.name === 'OverconstrainedError') {
return navigator.mediaDevices.getUserMedia(kind === 'video' ? { video: true } : { audio: true })
}
throw err
}
}
function deviceCheckHint(video: DeviceCheckStatus, audio: DeviceCheckStatus): string | null {
const videoDenied = video === 'denied'
const audioDenied = audio === 'denied'
if (videoDenied && audioDenied) {
return 'Доступ к камере и микрофону не разрешён — включить их можно будет прямо в конференции'
}
if (videoDenied) {
return 'Камера недоступна — включить её можно будет прямо в конференции'
}
if (audioDenied) {
return 'Микрофон недоступен — включить его можно будет прямо в конференции'
}
return null
}

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query'
import { listMyBackgrounds, type MyBackgroundsResponse } from '@/api/users'
/** Общий ключ кэша — им же инвалидируют список после загрузки/удаления картинки. */
export const MY_BACKGROUNDS_QUERY_KEY = ['my-backgrounds'] as const
const EMPTY: MyBackgroundsResponse = { items: [], limit: 0 }
/**
* Свои картинки фона текущего пользователя.
*
* `enabled` выключает запрос там, где его делать нельзя или незачем: у ГОСТЯ
* нет профиля и эндпоинт ответил бы 401 (гостю доступны только дефолтные
* сцены), а при выключенном модуле список не нужен вовсе.
*
* Пока данных нет, возвращается пустой список — вызывающий код одинаково
* работает и до загрузки, и у гостя, и при выключенном модуле.
*/
export function useMyBackgrounds(enabled: boolean): MyBackgroundsResponse {
const { data } = useQuery({
queryKey: MY_BACKGROUNDS_QUERY_KEY,
queryFn: listMyBackgrounds,
enabled,
})
return data ?? EMPTY
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { getPublicSettings } from '@/api/public'
/**
* Публичные настройки инстанса (`GET /public/settings`) — общая точка для
* `LoginPage`/`JoinPage`, обе страницы публичные и `GET /admin/settings` им
* недоступен. Один и тот же `queryKey` на обеих страницах — переход
* login → join (или наоборот) не бьёт эндпоинт повторно, пока данные свежие.
*/
export function usePublicSettings() {
return useQuery({ queryKey: ['public-settings'], queryFn: getPublicSettings })
}

View File

@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import type { LocalVideoTrack } from 'livekit-client'
import type { BackgroundProcessorWrapper } from '@livekit/track-processors'
import {
createBackgroundProcessor,
loadBackgroundKey,
NO_BACKGROUND,
resolveBackgroundUrl,
saveBackgroundKey,
type BackgroundKey,
type CustomBackground,
} from '@/lib/virtualBackground'
export type VirtualBackgroundStatus = 'idle' | 'loading' | 'error'
export interface UseVirtualBackgroundResult {
/** Текущий выбор (`none` / `default:<id>` / `custom:<uuid>`). */
backgroundKey: BackgroundKey
/** Сменить фон; сразу же персистится (см. `saveBackgroundKey`). */
selectBackground: (key: BackgroundKey) => void
/** `loading` — идёт первая загрузка библиотеки/модели либо применение к треку. */
status: VirtualBackgroundStatus
}
/**
* Применение выбранного фона к ПУБЛИКУЕМОМУ треку камеры участника.
*
* Фон навешивается процессором на сам трек (`LocalVideoTrack.setProcessor`),
* а НЕ через пересоздание `RoomOptions`: ссылка на `roomOptions` в `RoomPage`
* обязана оставаться стабильной, иначе `LiveKitRoom` переподключается к
* комнате (см. комментарий там же).
*
* Что здесь неочевидно:
*
* 1. **Трек живёт не всё время.** Выключение камеры в тулбаре не «глушит»
* трек, а останавливает и снимает его с публикации; включение создаёт
* НОВЫЙ `LocalVideoTrack` — без процессора. Поэтому эффект синхронизации
* следит за идентичностью трека и навешивает фон заново на каждый новый.
* 2. **Смена камеры фон не теряет.** Переключение устройства
* (`setActiveMediaDevice` в `DeviceSettingsDialog`) идёт через
* `LocalTrack.restartTrack`, а тот сам перезапускает уже установленный
* процессор на новом источнике — трек при этом остаётся тем же объектом,
* и эффект ниже даже не срабатывает.
* 3. **Операции строго последовательны.** `setProcessor`/`stopProcessor`
* асинхронны и небыстры (первый раз — ещё и скачивание модели); быстрые
* клики по разным фонам без очереди наложились бы друг на друга и оставили
* трек в непредсказуемом состоянии. Всё проходит через `chainRef`.
* 4. **Смена картинки не пересоздаёт процессор** — `switchTo` меняет её на
* лету, без разрыва конвейера и видимых артефактов у других участников.
*/
export function useVirtualBackground(
enabled: boolean,
customBackgrounds: CustomBackground[],
): UseVirtualBackgroundResult {
const { cameraTrack } = useLocalParticipant()
const track = (cameraTrack?.track as LocalVideoTrack | undefined) ?? null
const [backgroundKey, setBackgroundKey] = useState<BackgroundKey>(loadBackgroundKey)
const [status, setStatus] = useState<VirtualBackgroundStatus>('idle')
const selectBackground = useCallback((key: BackgroundKey) => {
setBackgroundKey(key)
saveBackgroundKey(key)
}, [])
const processorRef = useRef<BackgroundProcessorWrapper | null>(null)
// К какому треку и с какой картинкой процессор реально привязан сейчас —
// именно ФАКТИЧЕСКОЕ состояние, а не желаемое: по нему эффект понимает,
// что делать, и не переустанавливает уже установленное.
const appliedRef = useRef<{ track: LocalVideoTrack | null; url: string | null }>({
track: null,
url: null,
})
const chainRef = useRef<Promise<void>>(Promise.resolve())
const desiredUrl = enabled ? resolveBackgroundUrl(backgroundKey, customBackgrounds) : null
// Выбранной своей картинки больше нет (пользователь удалил её в профиле) —
// сбрасываем выбор на «без фона» явно, чтобы состояние не осталось висеть
// указателем в пустоту. Правится во время рендера под охраной сравнения —
// тот же санкционированный приём, что и у `chatSeenCount` в `RoomPage`.
if (enabled && backgroundKey !== NO_BACKGROUND && desiredUrl === null) {
setBackgroundKey(NO_BACKGROUND)
saveBackgroundKey(NO_BACKGROUND)
}
useEffect(() => {
const applied = appliedRef.current
if (track === applied.track && desiredUrl === applied.url) return
let cancelled = false
const run = async () => {
if (cancelled) return
try {
// Трек сменился (камеру выключили/включили) — прежний процессор
// принадлежал прежнему треку, вместе с ним он и уходит.
if (track !== applied.track && processorRef.current) {
await processorRef.current.destroy()
processorRef.current = null
}
if (!track || !desiredUrl) {
if (track && processorRef.current) {
await track.stopProcessor()
await processorRef.current.destroy()
processorRef.current = null
}
appliedRef.current = { track, url: null }
setStatus('idle')
return
}
setStatus('loading')
if (processorRef.current) {
// Тот же трек, другая картинка — меняем на лету.
await processorRef.current.switchTo({ mode: 'virtual-background', imagePath: desiredUrl })
} else {
const processor = await createBackgroundProcessor(desiredUrl)
if (cancelled) {
await processor.destroy()
return
}
await track.setProcessor(processor)
processorRef.current = processor
}
appliedRef.current = { track, url: desiredUrl }
setStatus('idle')
} catch {
// Не смогли применить фон (нет поддержки, не доехала модель, трек
// умер по дороге) — фича необязательная, встреча продолжается без неё.
appliedRef.current = { track, url: null }
setStatus('error')
}
}
chainRef.current = chainRef.current.then(run, run)
return () => {
cancelled = true
}
}, [track, desiredUrl])
// Уход из комнаты: процессор держит конвейер обработки кадров и модель —
// без явного освобождения они пережили бы саму страницу.
useEffect(
() => () => {
const processor = processorRef.current
processorRef.current = null
if (processor) void processor.destroy()
},
[],
)
return { backgroundKey, selectBackground, status }
}

View File

@@ -0,0 +1,74 @@
/**
* Сжатие картинки фона перед отправкой на сервер.
*
* «При добавлении картинок ужимать их до приемлемого размера, чтобы не грузили
* БД» — требование задачи. Жмём именно на КЛИЕНТЕ: фон всё равно рендерится в
* браузере, а на backend нет Pillow, и ставить его ради одной операции незачем.
* Серверная валидация (тип по магическим байтам, лимит размера) при этом
* остаётся — клиенту верить нельзя, запрос может прийти и мимо интерфейса.
*/
/** Больше 1280 по длинной стороне фону не нужно — столько же у дефолтных сцен (1280×720). */
const MAX_SIDE_PX = 1280
/** Ступени качества WebP: жмём сильнее, только если с прошлой ступени не уложились. */
const QUALITY_STEPS = [0.82, 0.7, 0.6]
/** Цель по весу — 600 КБ. Серверный лимит вдвое больше (2 МБ), запас на подстраховку. */
const TARGET_BYTES = 600 * 1024
export class ImageDecodeError extends Error {}
/**
* Ужать картинку до `MAX_SIDE_PX` по длинной стороне и вернуть WebP-Blob.
*
* Пропорции сохраняются: кадрировать под 16:9 здесь нельзя — какая часть
* картинки важна, знает только пользователь, а сама библиотека замены фона
* вписывает изображение в кадр сама.
*/
export async function resizeImageForBackground(file: File): Promise<Blob> {
const bitmap = await decode(file)
try {
const scale = Math.min(1, MAX_SIDE_PX / Math.max(bitmap.width, bitmap.height))
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) throw new ImageDecodeError('canvas 2d context unavailable')
context.drawImage(bitmap, 0, 0, width, height)
let result: Blob | null = null
for (const quality of QUALITY_STEPS) {
result = await toBlob(canvas, quality)
if (result.size <= TARGET_BYTES) return result
}
// Даже на самом низком качестве не уложились (огромная детализованная
// картинка) — отдаём как есть: серверный лимит вдвое выше цели, и шанс
// пройти его остаётся; иначе пользователь получит честную 413.
return result!
} finally {
bitmap.close()
}
}
/** Декодировать файл в `ImageBitmap`; битый/не-картинка → `ImageDecodeError`. */
async function decode(file: File): Promise<ImageBitmap> {
try {
return await createImageBitmap(file)
} catch (err) {
throw new ImageDecodeError(String(err))
}
}
function toBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new ImageDecodeError('canvas.toBlob returned null'))),
'image/webp',
quality,
)
})
}

View File

@@ -0,0 +1,39 @@
/**
* Замена фона — фича ТОЛЬКО для десктопа (решение оператора от 09.08.2026).
*
* Причина не в верстке, а в цене: сегментация силуэта считается нейросетью на
* КАЖДОМ кадре, пока фон включён. На телефоне это греет устройство, ест
* батарею и просаживает FPS в самой встрече — портить основное ради украшения
* нельзя.
*
* ⚠️ Поэтому «десктоп» здесь определяется по ВОЗМОЖНОСТЯМ УСТРОЙСТВА, а не по
* ширине окна. `useIsCompactViewport` (мобильный брейкпоинт 600px) для этого
* не годится принципиально: узкое окно на десктопе — это по-прежнему десктоп с
* его процессором, и прятать там фичу неправильно, а планшет с широким экраном
* остаётся устройством, которое сегментация нагреет.
*/
/**
* Признаки указывающего устройства:
* - `pointer: fine` — точный указатель (мышь/трекпад), у пальца он `coarse`;
* - `hover: hover` — указатель может «зависать» над элементом, чего тач не умеет.
*
* Проверяются ОБА: гибриды вроде ноутбука с сенсорным экраном сообщают
* `any-pointer: coarse`, но основным указателем у них остаётся мышь — такое
* устройство десктопное, и фича на нём должна быть.
*/
const DESKTOP_POINTER_QUERY = '(pointer: fine) and (hover: hover)'
/**
* Является ли устройство десктопным (и, значит, можно ли предлагать замену фона).
*
* Второе условие — `maxTouchPoints`: iPad в Safari по умолчанию притворяется
* десктопом (десктопный user-agent, `pointer: fine` при подключённом
* трекпаде), но остаётся планшетом с планшетным теплопакетом. Больше двух
* точек касания — это тач-устройство, сколько бы мышей к нему ни подключили.
*/
export function isDesktopDevice(): boolean {
if (typeof window === 'undefined') return false
if (!window.matchMedia(DESKTOP_POINTER_QUERY).matches) return false
return navigator.maxTouchPoints <= 2
}

View File

@@ -0,0 +1,29 @@
import type { ScreenShareCaptureOptions } from 'livekit-client'
/**
* Опции захвата демонстрации экрана: `audio: true` — звук
* вкладки/экрана там, где браузер его отдаёт (Chrome/Edge — вкладка почти
* всегда, целый экран — только Windows); `selfBrowserSurface: 'exclude'`
* — не предлагать в списке
* источников собственную вкладку (зеркальный туннель самой конференции);
* `surfaceSwitching: 'include'` — разрешить переключать источник прямо во
* время демонстрации, не останавливая её; `systemAudio: 'include'` — не
* запрещать захват системного звука при выборе «весь экран».
*
* Вынесено в модульную константу — `useTrackToggle` держит
* `JSON.stringify(captureOptions)` в зависимостях внутреннего `useMemo`,
* инлайновый литерал был бы безвреден, но константа явнее фиксирует
* неизменность опций.
*
* Живёт в `lib/`, а не рядом с тулбаром, потому что кнопок демонстрации теперь
* ДВЕ: в основном тулбаре (`RoomToolbar`) и в мини-окне (`RoomStage`,
* `PipScreenShareToggle`). Опции у них обязаны совпадать: обе кнопки управляют
* одной и той же публикацией, и разойдись они хотя бы в `audio`, демонстрация
* получалась бы разной в зависимости от того, откуда её запустили.
*/
export const SCREEN_SHARE_CAPTURE_OPTIONS: ScreenShareCaptureOptions = {
audio: true,
selfBrowserSurface: 'exclude',
surfaceSwitching: 'include',
systemAudio: 'include',
}

View File

@@ -0,0 +1,212 @@
/**
* Замена фона видео: список дефолтных сцен, память о выборе и ленивое создание
* процессора сегментации.
*
* Тяжёлая часть (библиотека + wasm-рантайм MediaPipe + модель, единицы мегабайт)
* НЕ попадает в основной бандл: `@livekit/track-processors` подключается
* динамическим `import()` в `createBackgroundProcessor`, то есть только когда
* пользователь реально включает фон. Вход в конференцию от наличия этой фичи
* не становится медленнее — это было прямым требованием.
*
* 🔴 Ассеты берутся СО СВОЕГО домена (`assetPaths` ниже). По умолчанию
* библиотека тянет wasm с jsdelivr, а модель — с storage.googleapis.com;
* VidConf ставят в закрытых контурах без внешнего интернета, и там фича молча
* не заработала бы. Откуда берутся файлы — см. плагин `mediapipeWasm`
* в `vite.config.ts` и `public/mediapipe/NOTICE.txt`.
*/
import { Track } from 'livekit-client'
import type { BackgroundProcessorWrapper } from '@livekit/track-processors'
/** Готовая сцена, поставляемая с продуктом (собственные рисунки, см. `design/backgrounds/`). */
export interface DefaultBackground {
/** Имя файла без расширения — оно же часть ключа выбора (`default:office`). */
id: string
label: string
url: string
}
export const DEFAULT_BACKGROUNDS: DefaultBackground[] = [
{ id: 'office', label: 'Офис', url: '/backgrounds/office.webp' },
{ id: 'beach', label: 'Пляж', url: '/backgrounds/beach.webp' },
{ id: 'space-station', label: 'Космическая станция', url: '/backgrounds/space-station.webp' },
]
/**
* Ключ выбранного фона: `none`, `default:<id>` либо `custom:<uuid записи>`.
*
* Хранится именно ключ, а не URL картинки: URL своей картинки перестаёт быть
* валидным, как только пользователь её удалил, и по ключу это видно сразу —
* записи с таким id в списке нет, значит выбор сбрасывается на «без фона»
* (см. `resolveBackgroundUrl`).
*/
export type BackgroundKey = string
export const NO_BACKGROUND: BackgroundKey = 'none'
const STORAGE_KEY = 'vidconf.virtualBackground'
/**
* Загрузить сохранённый выбор фона.
*
* Выбор персистится между заходами (как режим показа сцены,
* `lib/stageLayoutMode.ts`) и, что важнее, переживает переход «превью на входе
* → комната»: пользователь выбирает фон на `JoinPage`, а применяется он к
* публикуемому треку уже внутри конференции — передавать его через
* navigation state нельзя, тот теряется при F5.
*/
export function loadBackgroundKey(): BackgroundKey {
try {
return window.localStorage.getItem(STORAGE_KEY) || NO_BACKGROUND
} catch {
// Приватный режим/запрет хранилища — фича должна работать и без памяти.
return NO_BACKGROUND
}
}
export function saveBackgroundKey(key: BackgroundKey): void {
try {
window.localStorage.setItem(STORAGE_KEY, key)
} catch {
// См. `loadBackgroundKey` — молча живём без персиста.
}
}
/** Своя картинка пользователя в том виде, в каком её отдаёт API. */
export interface CustomBackground {
id: string
url: string
}
/**
* URL картинки по ключу выбора; `null` — фон не нужен («без фона» либо ключ
* указывает на уже удалённую свою картинку).
*/
export function resolveBackgroundUrl(
key: BackgroundKey,
customBackgrounds: CustomBackground[],
): string | null {
if (key.startsWith('default:')) {
const id = key.slice('default:'.length)
return DEFAULT_BACKGROUNDS.find((item) => item.id === id)?.url ?? null
}
if (key.startsWith('custom:')) {
const id = key.slice('custom:'.length)
return customBackgrounds.find((item) => item.id === id)?.url ?? null
}
return null
}
/**
* Поддерживает ли браузер замену фона.
*
* Проверка СИНХРОННАЯ и намеренно не трогает саму библиотеку: решение нужно
* ДО её загрузки, чтобы не показывать кнопку, которая не сработает, и не
* тянуть мегабайты впустую. Поэтому здесь буквально повторено условие
* `supportsBackgroundProcessors()` из `@livekit/track-processors` — при
* обновлении пакета сверять с ним.
*
* ⚠️ Конвейеров у библиотеки ДВА, и требовать современный нельзя:
* - современный (`MediaStreamTrackProcessor`/`Generator`, Insertable Streams)
* есть только в Chrome и производных;
* - запасной рисует кадры в canvas и отдаёт `canvas.captureStream()` — он
* работает в Safari и Firefox.
*
* Первая версия (0.0.35) требовала именно современный конвейер — и фича
* молча отсутствовала в Safari и Firefox, хотя запасной путь там доступен.
* Условие ниже — «умеет считать сегментацию» И «есть хоть какой-то конвейер».
*/
export function isVirtualBackgroundSupported(): boolean {
if (typeof window === 'undefined') return false
return canRunSegmentation() && hasAnyFramePipeline()
}
/**
* Условие `BackgroundTransformer.isSupported`: чем библиотека считает маску и
* собирает кадр. WebGL2 проверяется созданием пробного контекста — иначе никак,
* но результат кэшируется на всю жизнь страницы: браузеры держат ограниченное
* число живых WebGL-контекстов, и создавать новый на каждый рендер нельзя.
*/
let segmentationSupport: boolean | null = null
function canRunSegmentation(): boolean {
if (segmentationSupport !== null) return segmentationSupport
const hasApis =
typeof OffscreenCanvas !== 'undefined' &&
typeof VideoFrame !== 'undefined' &&
typeof createImageBitmap !== 'undefined'
if (!hasApis) {
segmentationSupport = false
return false
}
const probe = document.createElement('canvas').getContext('webgl2')
// Пробный контекст сразу отпускаем — он больше не нужен, а слот в лимите
// браузера занимал бы до сборки мусора.
probe?.getExtension('WEBGL_lose_context')?.loseContext()
segmentationSupport = Boolean(probe)
return segmentationSupport
}
/** Условие `ProcessorWrapper.isSupported`: современный конвейер ЛИБО запасной на canvas. */
function hasAnyFramePipeline(): boolean {
const modern = 'MediaStreamTrackProcessor' in window && 'MediaStreamTrackGenerator' in window
const fallback =
typeof HTMLCanvasElement !== 'undefined' && 'captureStream' in HTMLCanvasElement.prototype
return modern || fallback
}
/**
* Ассеты MediaPipe со своего домена — см. заголовок файла и `vite.config.ts`.
*
* `tasksVisionFileSet` — КАТАЛОГ с wasm-рантаймом: библиотека сама выберет
* simd- или nosimd-вариант по возможностям браузера, поэтому в каталоге лежат
* оба. `modelAssetPath` — конкретный файл модели сегментации.
*/
const LOCAL_ASSET_PATHS = {
tasksVisionFileSet: '/mediapipe/wasm',
modelAssetPath: '/mediapipe/selfie_segmenter.tflite',
}
/**
* Создать процессор замены фона на картинку `imagePath`.
*
* Библиотека грузится динамическим `import()` — первый вызов скачивает её
* вместе с wasm и моделью, последующие берут из кэша модулей/браузера.
*/
export async function createBackgroundProcessor(
imagePath: string,
): Promise<BackgroundProcessorWrapper> {
const { BackgroundProcessor } = await import('@livekit/track-processors')
return BackgroundProcessor({
mode: 'virtual-background',
imagePath,
assetPaths: LOCAL_ASSET_PATHS,
})
}
/**
* Запустить процессор на «сыром» треке камеры — для превью входа, где
* LiveKit-трека ещё нет (в комнате всё это делает сам
* `LocalVideoTrack.setProcessor`).
*
* ⚠️ Процессору обязателен `element` — `<video>`, в который проигрывается
* ИСХОДНЫЙ поток: библиотека читает из него кадры и падает
* `TypeError: Currently only video transformers are supported`, если элемента
* нет. Порядок ровно как у самого LiveKit (`LocalTrack.setProcessor`):
* сначала `init`, только потом подключение потока и `play()`.
*
* Элемент в DOM не добавляется — он служебный, зритель видит уже обработанный
* поток; возвращается вызывающему, чтобы тот освободил его вместе с процессором.
*/
export async function startProcessorOnTrack(
processor: BackgroundProcessorWrapper,
rawTrack: MediaStreamTrack,
): Promise<HTMLVideoElement> {
const element = document.createElement('video')
await processor.init({ kind: Track.Kind.Video, track: rawTrack, element })
element.muted = true
element.playsInline = true
element.autoplay = true
element.srcObject = new MediaStream([rawTrack])
await element.play()
return element
}

View File

@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { getRegistrationOptions } from '@/api/auth'
import { AppFooter } from '@/components/layout/AppFooter'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import '@/styles/legal.css'
/**
* Публичная страница регламента обработки персональных данных
* (`/legal/personal-data-consent`) — ссылка рядом с галочкой согласия на
* форме регистрации (`RegisterPage`). Текст и номер редакции берутся из
* того же публичного `GET /auth/registration-options`, которым пользуется
* форма регистрации — отдельного эндпоинта под это специально не заводили.
*
* Страница доступна ВСЕГДА, независимо от `consent_required`: если модуль
* выключен, регламент просто не обязателен для регистрации, но ссылка на
* него не должна вести в никуда — администратор мог оставить текст
* заполненным про запас или для внешней ссылки.
*/
export function ConsentPolicyPage() {
const { data, isLoading } = useQuery({
queryKey: ['auth', 'registration-options'],
queryFn: getRegistrationOptions,
})
return (
<div className="legal-shell">
<header className="legal-topbar">
<Link to="/lobby" className="brand-mark">
<LogoMark /> VidConf
</Link>
<ThemeToggle />
</header>
<main className="legal-main">
<article className="legal-card">
<h1>Регламент обработки персональных данных</h1>
{isLoading && <p className="legal-empty">Загрузка</p>}
{!isLoading && data && data.consent_text.trim() && (
<>
<span className="legal-version">Редакция {data.consent_version}</span>
<div className="legal-text">{data.consent_text}</div>
</>
)}
{!isLoading && data && !data.consent_text.trim() && (
<p className="legal-empty">
Регламент обработки персональных данных для этого инстанса ещё не заполнен
администратором.
</p>
)}
</article>
</main>
<AppFooter />
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More