Compare commits

...

30 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
100 changed files with 5615 additions and 263 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.27
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,304 @@
Формат основан на [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
Разрыв связи больше не выбрасывает участника из конференции.

View File

@@ -1 +1 @@
0.0.27
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

@@ -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.27}
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

@@ -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

@@ -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

@@ -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

@@ -1,5 +1,5 @@
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,
@@ -13,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'
/**
@@ -115,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" />}
@@ -123,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 при нескольких
@@ -441,12 +496,26 @@ export function RoomStage({
// демонстрации, антидребезг говорящего) — переиспользуем как есть.
// Булавки на плитке здесь нет намеренно: своего тулбара у мини-окна нет,
// закрепление делается в основном окне и приезжает сюда через
// `initialPinnedKey`.
// `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 />
<PipScreenShareToggle />
</div>
</section>
)
}

View File

@@ -1,5 +1,6 @@
import {
Hand,
Image,
LogOut,
Maximize,
MessageSquare,
@@ -13,34 +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 { useIsOrganizer } from '@/hooks/useIsOrganizer'
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). */
@@ -59,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`
@@ -94,9 +86,11 @@ interface RoomToolbarProps extends StageViewProps {
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору —
* встречается редко, но по той же причине оставлена в тулбаре, а не в
* шторке: организатору с телефона тоже нужно видеть очередь сразу.
* недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* «28-hand-queue-for-all»: раньше только организатору), по той же причине
* оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
@@ -115,6 +109,10 @@ export function RoomToolbar({
pipSupported,
pipActive,
onTogglePiP,
handQueueEnabled,
backgroundVisible,
backgroundOpen,
onToggleBackground,
handQueue,
onRaiseHand,
onLowerHand,
@@ -128,22 +126,26 @@ export function RoomToolbar({
}: RoomToolbarProps) {
const toast = useToast()
const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
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).
// в 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 =
3 /* микрофон, камера, рука */ +
(isOrganizer ? 1 : 0) /* очередь */ +
2 /* микрофон, камера */ +
(handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ +
// На телефоне всегда 0 (фича десктопная), но десктопное окно бывает и уже
// 600px — тогда кнопка «Фон» реально есть и должна попасть в подсчёт.
(backgroundVisible ? 1 : 0) +
(fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) +
1 /* выйти */
@@ -214,6 +216,7 @@ export function RoomToolbar({
<span className="label">Демонстрация</span>
</button>
{handQueueEnabled && (
<button
type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
@@ -229,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
@@ -256,6 +260,23 @@ export function RoomToolbar({
<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"

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

@@ -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>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
import {
@@ -10,8 +10,21 @@ import {
import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AppFooter } from '@/components/layout/AppFooter'
import { DeviceCheckCard } from '@/components/auth/DeviceCheckCard'
import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { useDeviceCheckAccess } from '@/hooks/useDeviceCheckAccess'
import { useMyBackgrounds } from '@/hooks/useMyBackgrounds'
import { BackgroundPicker } from '@/components/background/BackgroundPicker'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import {
isVirtualBackgroundSupported,
loadBackgroundKey,
resolveBackgroundUrl,
saveBackgroundKey,
type BackgroundKey,
} from '@/lib/virtualBackground'
import { usePublicSettings } from '@/hooks/usePublicSettings'
import { parseJoinQuery } from '@/lib/parseJoinQuery'
import '@/styles/join.css'
@@ -67,6 +80,70 @@ export function JoinPage() {
const autoResolvedRef = useRef(false)
// Проверка устройств на входе (сессия 34, правка к сессии 33) — теперь на
// ДВУХ шагах: «Подключиться к конференции» (`input`) и «Как вас зовут?»
// (`guest-info`), не только на гостевом. Это закрывает реальную дыру:
// авторизованный пользователь, входящий через /join, шаг `guest-info`
// вообще не проходит (см. `handleResolve` — сразу `connecting`), и без
// превью на `input` проверка устройств и кнопки «войти с камерой/
// микрофоном» ему были бы недоступны никогда. Отключаемый модуль, дефолт
// выключен — см. `instance_settings.device_check`. Кнопки «войти с
// камерой/микрофоном», в отличие от LoginPage, РЕАЛЬНО определяют
// состояние входа — см. `performJoin` (решение оператора, сессия 33).
const { data: publicSettings } = usePublicSettings()
const deviceCheckModuleEnabled = publicSettings?.device_check_enabled ?? false
// Флаг «мы сейчас на одном из шагов с превью» — ОБЩИЙ для обоих шагов, а
// не сравнение с конкретным `step`, иначе переход `input → guest-info`
// (тот же самый непрерывный проход, а не уход с превью) выглядел бы для
// эффекта ниже как «ушли → зашли снова»: React перезапускает эффект при
// ЛЮБОЙ смене значения в зависимостях, а `step` меняется на каждом шаге.
// С единым булевым флагом смена `input → guest-info` не меняет флаг
// (true → true) — эффект не перезапускается, поток не мигает и не
// перезахватывается (что вдобавок раскрутило бы известную грабли с
// deviceId `"default"`, см. `useDeviceCheckAccess.ts`).
const inDeviceCheckSteps = deviceCheckModuleEnabled && (step === 'input' || step === 'guest-info')
// Замена фона (сессия 35) прямо в превью: выбранный здесь фон и виден в
// окошке, и переживает вход — в комнате он применится к публикуемому треку
// (общий сохранённый выбор, `lib/virtualBackground.ts`). Условия те же, что
// и в комнате: модуль включён в админке + ДЕСКТОПНОЕ устройство (по
// возможностям, не по ширине окна) + браузер умеет сегментацию.
const [backgroundCapable] = useState(
() => isDesktopDevice() && isVirtualBackgroundSupported(),
)
const backgroundPickerVisible =
backgroundCapable && (publicSettings?.virtual_background_enabled ?? false)
const [backgroundKey, setBackgroundKey] = useState<BackgroundKey>(loadBackgroundKey)
// Свои картинки есть только у авторизованного пользователя; гостю доступны
// лишь дефолтные сцены (профиля у него нет, эндпоинт ответил бы 401).
const { items: myBackgrounds } = useMyBackgrounds(
backgroundPickerVisible && status === 'authenticated',
)
const backgroundUrl = backgroundPickerVisible
? resolveBackgroundUrl(backgroundKey, myBackgrounds)
: null
const handleBackgroundChange = useCallback((key: BackgroundKey) => {
setBackgroundKey(key)
saveBackgroundKey(key)
}, [])
const deviceCheck = useDeviceCheckAccess(inDeviceCheckSteps, backgroundUrl)
// Деструктурируем то, что нужно эффекту ниже, — `deviceCheck` целиком
// пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по
// себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект
// в зависимостях).
const { release: releaseDeviceCheck } = deviceCheck
// Шаги флоу — условный рендер ОДНОГО и того же компонента (не
// размонтируют JoinPage), поэтому размонтирование хука само по себе
// камеру не освободит: уход С ОБОИХ шагов превью (вперёд — пароль/
// коннект, назад — в лобби) должен погасить камеру явно. Эффект с
// cleanup по смене `inDeviceCheckSteps` (не `step`!) ловит именно уход из
// группы шагов одним местом, а не любой шаг внутри нее.
useEffect(() => {
if (!inDeviceCheckSteps) return
return () => releaseDeviceCheck()
}, [inDeviceCheckSteps, releaseDeviceCheck])
// Прямая ссылка (/j/:slug) или ?q= из редиректа — резолвим сразу, не
// дожидаясь клика по «Продолжить», но только после того как известен
// статус сессии (нужен для выбора «гость / авторизованный» флоу).
@@ -138,6 +215,13 @@ export function JoinPage() {
password: opts.password,
})
}
// Явное освобождение ДО навигации, а не расчёт на автоматическую
// отписку хука при размонтировании: RoomPage.tsx монтируется и сам
// запрашивает камеру/микрофон (если enterWithVideo/enterWithAudio)
// почти сразу — если наш поток превью к этому моменту ещё не
// остановлен, устройство может достаться LiveKit «занятым» (см.
// требование задачи про свободную камеру к моменту входа).
releaseDeviceCheck()
navigate(`/room/${encodeURIComponent(resolved.query)}`, {
replace: true,
state: {
@@ -146,8 +230,18 @@ export function JoinPage() {
title: resolved.title,
conferenceId: data.conference_id,
chatEnabled: data.chat_enabled,
handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles,
virtualBackgroundEnabled: data.virtual_background_enabled,
// Кнопки превью «войти с камерой/микрофоном» — теперь доступны и
// гостю (input → guest-info → сюда), и авторизованному пользователю
// (input → connecting → сюда, guest-info он не проходит вовсе, см.
// докстринг выше) одним и тем же `deviceCheck`, живущим с шага
// `input`. При модуле выключенном оба флага остаются `false`,
// RoomPage ведёт себя как раньше.
enterWithVideo: deviceCheck.videoEnabled,
enterWithAudio: deviceCheck.audioEnabled,
},
})
} catch (err) {
@@ -214,10 +308,41 @@ export function JoinPage() {
<main className="join-main">
{step === 'input' && (
<section className="screen-block">
<div className="flow-card">
<div
className="flow-card"
onPointerDown={deviceCheck.triggerOnGesture}
onKeyDown={deviceCheck.triggerOnGesture}
>
{deviceCheckModuleEnabled ? (
<DeviceCheckCard
videoRef={deviceCheck.videoRef}
videoStatus={deviceCheck.videoStatus}
audioStatus={deviceCheck.audioStatus}
hint={deviceCheck.hint}
videoEnabled={deviceCheck.videoEnabled}
audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled}
backgroundPicker={
backgroundPickerVisible ? (
<>
<BackgroundPicker
value={backgroundKey}
onChange={handleBackgroundChange}
customBackgrounds={myBackgrounds}
/>
{deviceCheck.backgroundFailed && (
<p className="bg-hint">Не удалось применить фон в превью</p>
)}
</>
) : undefined
}
/>
) : (
<div className="flow-icon" aria-hidden="true">
<Link2 className="icon" aria-hidden="true" />
</div>
)}
<h1>Подключиться к конференции</h1>
<p className="flow-sub">Вставьте ссылку-приглашение или введите номер конференции</p>
@@ -257,7 +382,11 @@ export function JoinPage() {
{step === 'guest-info' && target && (
<section className="screen-block">
<div className="flow-card">
<div
className="flow-card"
onPointerDown={deviceCheck.triggerOnGesture}
onKeyDown={deviceCheck.triggerOnGesture}
>
<div className="target-conf">
<span className="icon-wrap" aria-hidden="true">
{target.isClosed ? <Lock className="icon" aria-hidden="true" /> : <Video className="icon" aria-hidden="true" />}
@@ -268,9 +397,36 @@ export function JoinPage() {
</div>
</div>
{deviceCheckModuleEnabled ? (
<DeviceCheckCard
videoRef={deviceCheck.videoRef}
videoStatus={deviceCheck.videoStatus}
audioStatus={deviceCheck.audioStatus}
hint={deviceCheck.hint}
videoEnabled={deviceCheck.videoEnabled}
audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled}
backgroundPicker={
backgroundPickerVisible ? (
<>
<BackgroundPicker
value={backgroundKey}
onChange={handleBackgroundChange}
customBackgrounds={myBackgrounds}
/>
{deviceCheck.backgroundFailed && (
<p className="bg-hint">Не удалось применить фон в превью</p>
)}
</>
) : undefined
}
/>
) : (
<div className="flow-icon" aria-hidden="true">
<User className="icon" aria-hidden="true" />
</div>
)}
<h1>Как вас зовут?</h1>
<p className="flow-sub">У вас нет аккаунта VidConf представьтесь, чтобы войти как гость</p>
@@ -307,6 +463,7 @@ export function JoinPage() {
</div>
<p className="field-hint">Укажите, если хотите получить саммари конференции на почту</p>
</div>
<button type="submit" className="btn btn-primary" disabled={!guestName.trim() || joining}>
{joining ? 'Входим…' : 'Войти в конференцию'}
{!joining && <ArrowRight style={{ width: 18, height: 18 }} aria-hidden="true" />}

View File

@@ -43,8 +43,10 @@ export function LobbyPage() {
title: conference.title,
conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled,
handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles,
virtualBackgroundEnabled: conference.join.virtual_background_enabled,
number: conference.number,
slug: conference.slug,
},

View File

@@ -3,6 +3,8 @@ import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { AlertCircle, ArrowRight, Eye, EyeOff, Lock, Mail } from 'lucide-react'
import { AuthLayout } from '@/components/auth/AuthLayout'
import { useAuth } from '@/auth/useAuth'
import { useDeviceCheckAccess } from '@/hooks/useDeviceCheckAccess'
import { usePublicSettings } from '@/hooks/usePublicSettings'
import { ApiError } from '@/api/client'
/** Экран входа (см. design/mockups/auth.html, блок «вход в аккаунт»). */
@@ -17,6 +19,21 @@ export function LoginPage() {
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
// Проверка устройств на входе (сессия 33) — отключаемый модуль, дефолт
// выключен (см. `instance_settings.device_check`). `enabled=false` держит
// хук полностью инертным — старое поведение сохраняется как есть.
//
// На LoginPage — без видимого превью и кнопок (решение оператора,
// сессия 33: LoginPage ведёт в лобби, а не в конкретную конференцию,
// применить выбор «войти с камерой» в моменте физически некуда, а
// кнопки без действия только путают). Сам ранний запрос доступа
// остаётся — это по-прежнему пре-авторизация разрешения, чтобы позже,
// уже в конференции, при включении камеры/микрофона не всплывал системный
// диалог. Освобождение потока — через `release()` внутри самого хука при
// размонтировании страницы (успешный логин уводит на другой маршрут).
const { data: publicSettings } = usePublicSettings()
const deviceCheck = useDeviceCheckAccess(publicSettings?.device_check_enabled ?? false)
// Уже вошли — незачем показывать форму логина повторно.
if (status === 'authenticated') {
return <Navigate to="/lobby" replace />
@@ -55,7 +72,12 @@ export function LoginPage() {
</Link>
</div>
<form onSubmit={handleSubmit} noValidate>
<form
onSubmit={handleSubmit}
noValidate
onPointerDown={deviceCheck.triggerOnGesture}
onKeyDown={deviceCheck.triggerOnGesture}
>
<div className="field">
<label htmlFor="login-email">Рабочая почта</label>
<div className="input-wrap">

View File

@@ -8,6 +8,8 @@ import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
import { useAuth } from '@/auth/useAuth'
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users'
import { MyBackgroundsSection } from '@/components/background/MyBackgroundsSection'
import { usePublicSettings } from '@/hooks/usePublicSettings'
import { ApiError, errorDetail } from '@/api/client'
import '@/styles/lobby.css'
import '@/styles/profile.css'
@@ -38,6 +40,11 @@ export function ProfilePage() {
const [avatarError, setAvatarError] = useState<string | null>(null)
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
// Раздел «Свои фоны» есть, только пока модуль включён в админке — иначе
// картинкам, которые никак не применить, в профиле не место (то же правило,
// что и с кнопкой «Фон» в комнате: выключенный модуль не оставляет мёртвых
// элементов интерфейса).
const { data: publicSettings } = usePublicSettings()
const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
async function afterProfileChange() {
@@ -216,6 +223,8 @@ export function ProfilePage() {
</form>
</section>
{publicSettings?.virtual_background_enabled && <MyBackgroundsSection />}
<section className="profile-card">
<h2>Смена пароля</h2>
<form onSubmit={handlePasswordSubmit}>

View File

@@ -6,6 +6,8 @@ import { AuthLayout } from '@/components/auth/AuthLayout'
import { getRegistrationOptions, register } from '@/api/auth'
import { ApiError, errorDetail } from '@/api/client'
const CONSENT_POLICY_PATH = '/legal/personal-data-consent'
/** Текст предупреждения о несовпадении домена почты с эталонными. */
function domainMismatchMessage(domains: string[]): string {
const list = domains.map((domain) => `@${domain}`).join(' или ')
@@ -23,12 +25,19 @@ function domainMismatchMessage(domains: string[]): string {
* 400 `invalid_email_domain`.
* После успешной регистрации показывает состояние «подтвердите почту»
* (письмо со ссылкой backend в dev-режиме печатает в консоль).
* Галочка согласия на обработку персональных данных (`consent_required`)
* показывается только при включённой настройке инстанса; ссылка рядом
* ведёт на публичную страницу регламента (`ConsentPolicyPage`), которая
* берёт тот же текст/версию из `registration-options`. Кнопка отправки
* заблокирована, пока галочка не отмечена, — сервер отдельно перепроверяет
* это же условие (`POST /auth/register` отвечает 400 `consent_required`).
*/
export function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [consentAccepted, setConsentAccepted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [emailDomainError, setEmailDomainError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
@@ -41,6 +50,7 @@ export function RegisterPage() {
const teamChoiceEnabled = registrationOptions?.team_choice_enabled ?? false
const teams = registrationOptions?.teams ?? []
const expectedEmailDomains = registrationOptions?.email_domains ?? []
const consentRequired = registrationOptions?.consent_required ?? false
/** Домен после «@» не совпадает (без учёта регистра) ни с одним эталонным — `null`, если сверять не с чем. */
function checkEmailDomain(value: string): string | null {
@@ -61,16 +71,25 @@ export function RegisterPage() {
const domainError = checkEmailDomain(email)
setEmailDomainError(domainError)
if (domainError) return
if (consentRequired && !consentAccepted) return
setPending(true)
try {
await register({ email, name_user: name, password, team_id: teamChoiceEnabled ? teamId || null : null })
await register({
email,
name_user: name,
password,
team_id: teamChoiceEnabled ? teamId || null : null,
consent_accepted: consentAccepted,
})
setRegisteredEmail(email)
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('Пользователь с таким email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_email_domain') {
setEmailDomainError(expectedEmailDomains.length > 0 ? domainMismatchMessage(expectedEmailDomains) : 'Регистрация с этим доменом почты недоступна')
} else if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'consent_required') {
setError('Отметьте согласие на обработку персональных данных')
} else {
setError('Не удалось зарегистрироваться. Проверьте данные и попробуйте снова')
}
@@ -185,7 +204,29 @@ export function RegisterPage() {
<p className="hint-msg">Минимум 8 символов, буквы и цифры</p>
)}
</div>
<button type="submit" className="btn btn-primary btn-block btn-lg" disabled={pending}>
{consentRequired && (
<label className="checkbox-row" style={{ marginBottom: 'var(--space-4)' }}>
<input
type="checkbox"
checked={consentAccepted}
onChange={(e) => setConsentAccepted(e.target.checked)}
/>
Согласен(на) на{' '}
<Link
to={CONSENT_POLICY_PATH}
target="_blank"
rel="noopener"
style={{ fontWeight: 700, textDecoration: 'underline' }}
>
обработку персональных данных
</Link>
</label>
)}
<button
type="submit"
className="btn btn-primary btn-block btn-lg"
disabled={pending || (consentRequired && !consentAccepted)}
>
{pending ? 'Регистрируем…' : 'Зарегистрироваться'}
</button>
</form>

View File

@@ -12,12 +12,15 @@ import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat'
import { useFullscreen } from '@/hooks/useFullscreen'
import { useRoomPiP } from '@/hooks/useRoomPiP'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import { isVirtualBackgroundSupported } from '@/lib/virtualBackground'
import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { VirtualBackgroundController } from '@/components/room/VirtualBackgroundController'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
@@ -55,6 +58,8 @@ interface RoomJoinState {
number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся. */
handQueueEnabled?: boolean
/**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
@@ -67,6 +72,17 @@ interface RoomJoinState {
*/
publishQualityCap?: PublishQualityCap
stageMaxTiles?: number
/** `JoinOut.virtual_background_enabled` — при `false` кнопки «Фон» нет вовсе. */
virtualBackgroundEnabled?: boolean
/**
* Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на
* входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→
* `false`) для авторизованного входа мимо гостевой карточки, при
* выключенном модуле и при переподключении после F5 (эффект ниже) —
* тогда действует прежнее поведение `audio={false} video={false}` (0.0.15).
*/
enterWithVideo?: boolean
enterWithAudio?: boolean
}
/**
@@ -208,8 +224,10 @@ export function RoomPage() {
title: info.title,
conferenceId: result.conference_id,
chatEnabled: result.chat_enabled,
handQueueEnabled: result.hand_queue_enabled,
publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles,
virtualBackgroundEnabled: result.virtual_background_enabled,
})
}
} catch (err) {
@@ -260,10 +278,16 @@ export function RoomPage() {
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`).
// на каждой плитке сцены (см. `RoomParticipantTile`). Выключенный модуль
// (`handQueueEnabled=false`) гасит и эти значки — иначе на плитке мог бы
// повиснуть индикатор от руки, поднятой до отключения модуля (сервер
// хранит очередь в Redis независимо от тоггла, см. `services/hand_queue.py`).
const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)),
[chat.handQueue],
() =>
joinState?.handQueueEnabled
? new Set(chat.handQueue.map((entry) => entry.identity))
: new Set<string>(),
[chat.handQueue, joinState?.handQueueEnabled],
)
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
@@ -272,6 +296,20 @@ export function RoomPage() {
const fullscreen = useFullscreen(roomRootRef)
const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false)
const [backgroundOpen, setBackgroundOpen] = useState(false)
// Может ли этот участник вообще пользоваться заменой фона. Считается ОДИН
// раз при монтировании: и тип устройства, и поддержка конвейера обработки
// кадров браузером в пределах вкладки не меняются.
//
// ⚠️ Про десктоп — именно `isDesktopDevice` (возможности устройства), а НЕ
// `useIsCompactViewport` (ширина окна): узкое окно на десктопе — всё ещё
// десктоп, а широкий планшет — всё ещё планшет, который сегментация греет.
// Подробности — докстринг `lib/isDesktopDevice.ts`.
const [backgroundCapable] = useState(
() => isDesktopDevice() && isVirtualBackgroundSupported(),
)
const backgroundVisible = backgroundCapable && Boolean(joinState?.virtualBackgroundEnabled)
// Топбар и тулбар в полноэкранном режиме — оверлей поверх сцены (см.
// `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
@@ -414,8 +452,23 @@ export function RoomPage() {
// dynacast: паблишер прекращает отдавать слои, на которые нет подписчиков.
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: { deviceId: userChoices.audioDeviceId || undefined },
videoCaptureDefaults: { deviceId: userChoices.videoDeviceId || undefined },
// `usePersistentUserChoices` хранит «устройство не выбрано» как литерал
// `"default"` (`@livekit/components-core`, `defaultUserChoices`), а не
// пустую строку — `|| undefined` его не отсекает, и в Chrome это давало
// `OverconstrainedError` на первом визите (см. тот же сентинел в
// `useDeviceCheckAccess.ts::openStream`).
audioCaptureDefaults: {
deviceId:
userChoices.audioDeviceId && userChoices.audioDeviceId !== 'default'
? userChoices.audioDeviceId
: undefined,
},
videoCaptureDefaults: {
deviceId:
userChoices.videoDeviceId && userChoices.videoDeviceId !== 'default'
? userChoices.videoDeviceId
: undefined,
},
// Аудиовыход (колонки/наушники/bluetooth) — отдельный персист, не через
// usePersistentUserChoices: LocalUserChoices LiveKit про него не знает
// (см. lib/audioOutputDevice.ts). Читаем один раз при монтировании —
@@ -451,8 +504,8 @@ export function RoomPage() {
serverUrl={joinState.livekitUrl}
token={joinState.token}
connect
audio={false}
video={false}
audio={Boolean(joinState.enterWithAudio)}
video={Boolean(joinState.enterWithVideo)}
options={roomOptions}
onConnected={handleConnected}
onDisconnected={handleDisconnected}
@@ -528,6 +581,10 @@ export function RoomPage() {
pipSupported={pip.supported}
pipActive={pip.active}
onTogglePiP={pip.toggle}
handQueueEnabled={Boolean(joinState.handQueueEnabled)}
backgroundVisible={backgroundVisible}
backgroundOpen={backgroundOpen}
onToggleBackground={() => setBackgroundOpen((open) => !open)}
handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()}
@@ -540,6 +597,15 @@ export function RoomPage() {
onLeave={handleLeaveClick}
/>
</div>
{/* Смонтирован всё время, пока модуль включён, — фон живёт независимо
от того, открыто ли окно выбора (см. докстринг компонента). */}
{backgroundVisible && (
<VirtualBackgroundController
open={backgroundOpen}
onClose={() => setBackgroundOpen(false)}
canManageOwn={status === 'authenticated'}
/>
)}
{settingsOpen && (
<DeviceSettingsDialog
onClose={() => setSettingsOpen(false)}

View File

@@ -0,0 +1,95 @@
/*
* VidConf — превью камеры на входе (сессия 33, `JoinPage`; LoginPage
* превью не показывает — см. `DeviceCheckCard` не используется там).
* Свой файл: компонент самодостаточный, стили ему не нужны нигде за
* пределами компонента.
*/
.device-check { margin: 0 auto var(--space-5); }
/* Окошко превью и кнопки под ним — одна композиция шириной в половину
карточки (`.flow-card`, куда компонент вложен): проценты сами масштабируются
от фактической ширины родителя без хардкода пикселей. */
.device-check-inner { width: 50%; margin: 0 auto; }
.device-check-frame {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-bg-alt);
border: 1px solid var(--color-border);
}
.device-check-video {
width: 100%;
height: 100%;
object-fit: cover;
/* Своё видео принято показывать зеркально — так человек видит себя
привычно, как в зеркале, а не «как его видят другие». */
transform: scaleX(-1);
}
.device-check-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-ink-400);
}
.device-check-placeholder .lucide { width: 26px; height: 26px; }
/* Кнопки без подписи, только пиктограммы — их совместная ширина равна
ширине окошка превью (тот же `.device-check-inner`), за края не выступают. */
.device-check-toggles {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.device-check-toggle {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background: var(--color-bg-alt);
color: var(--color-ink-500);
cursor: pointer;
}
.device-check-toggle .lucide { width: 18px; height: 18px; }
.device-check-toggle.is-on {
background: var(--color-accent);
border-color: transparent;
color: var(--color-accent-text);
}
.device-check-toggle:disabled { opacity: 0.5; cursor: not-allowed; }
.device-check-hint {
text-align: center;
margin-top: var(--space-3);
}
/* Выбор фона под кнопками превью (сессия 35).
Здесь плитки идут ОДНОЙ прокручиваемой строкой, а не сеткой, как в комнате
и в профиле: карточка входа и без того высокая, а число своих картинок
доходит до десяти — сетка выросла бы на шесть рядов и увела бы кнопку
«Продолжить» за нижний край экрана. Строка держит высоту постоянной при
любом числе картинок. Сами плитки — общие, см. virtual-background.css. */
.device-check-backgrounds {
margin: var(--space-4) auto 0;
}
.device-check-backgrounds .bg-grid {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-2);
}
.device-check-backgrounds .bg-tile {
flex: 0 0 auto;
width: 72px;
}

View File

@@ -0,0 +1,48 @@
/*
* VidConf — стили публичной страницы регламента обработки персональных данных.
* Топбар/оболочка — по образцу `.join-shell`/`.join-topbar` (join.css):
* страница публичная (открывается до регистрации, без сессии), поэтому не
* использует `.page-shell`/`ShellTopbar`. Карточка шире, чем `.flow-card`
* (440px рассчитан на форму, не на читаемый текст документа).
*/
.legal-shell { min-height: 100vh; display: flex; flex-direction: column; background: var(--color-bg); }
.legal-topbar { display: flex; align-items: center; justify-content: space-between; padding: var(--space-5) var(--space-8); }
.legal-main {
flex: 1;
display: flex;
justify-content: center;
padding: var(--space-6) var(--space-8) var(--space-16);
}
.legal-card {
width: 100%;
max-width: 760px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-2xl);
padding: var(--space-8);
}
.legal-card h1 { font: var(--text-h1); color: var(--color-ink-900); margin: 0 0 var(--space-2); }
.legal-version {
display: inline-block;
font: var(--text-mono-sm);
color: var(--color-ink-500);
background: var(--color-bg-alt);
border-radius: var(--radius-full);
padding: 2px 12px;
margin-bottom: var(--space-6);
}
.legal-text {
font: var(--text-body);
color: var(--color-ink-700);
white-space: pre-wrap;
line-height: 1.6;
}
.legal-empty { font: var(--text-body); color: var(--color-ink-500); }

View File

@@ -133,18 +133,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
* `.stage-tiles` в основном окне) плюс подстраховочный `min-height`, чтобы
* плитка не схлопывалась, если видео ещё не выдало реальные размеры.
*/
.room-single-tile { position: relative; flex: 1; min-height: 0; display: flex; }
.room-single-tile {
position: relative;
flex: 1;
min-height: 0;
display: flex;
/* Ширина ряда кнопок мини-плеера (`.room-pip-controls`): две круглые кнопки
по 36px плюс зазор. Живёт на общем предке, а не на самом ряду, потому что
от неё считается и отступ плашки с именем участника — иначе подпись
(«Второй участник — демонстрация экрана») уезжает ПОД кнопки. Появится
третья кнопка — правится одно число, а не два места. */
--room-pip-controls-width: calc(2 * 36px + 0.375rem);
}
.room-single-tile .lk-participant-tile { flex: 1; min-height: 220px; width: 100%; }
/* Библиотечная плашка с именем прижата к обоим нижним углам (`left: 4px;
right: 4px`) и потому начинается ровно там, где лежат наши кнопки. Сдвигаем
её содержимое вправо на ширину ряда — сама плашка прозрачная, подпись внутри
со своим фоном, так что двигать надо именно отступ, а не `left`. */
.room-single-tile .lk-participant-metadata { padding-left: var(--room-pip-controls-width); }
/* Кнопка микрофона мини-плеера (задача A5) — своего тулбара в PiP нет,
кнопка лежит поверх единственной плитки левым нижним углом (правый занят
штатным `.lk-focus-toggle-button`/нашей булавкой закрепления — тут её нет,
в PiP закреплять нечего, но геометрия зеркалит `.room-pin-toggle`). */
.room-pip-mic-toggle {
/* Кнопки мини-плеера (микрофон — задача A5, демонстрация экрана — 0.0.29):
своего тулбара в PiP нет, кнопки лежат поверх единственной плитки левым
нижним углом (правый занят штатным `.lk-focus-toggle-button`/нашей булавкой
закрепления — тут её нет, в PiP закреплять нечего, но геометрия зеркалит
`.room-pin-toggle`). Ряд, а не две абсолютно спозиционированные кнопки:
так добавление третьей не потребует пересчёта координат. */
.room-pip-controls {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
z-index: 5;
display: flex;
gap: 0.375rem;
}
.room-pip-btn {
width: 36px;
height: 36px;
border-radius: 50%;
@@ -152,12 +174,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
display: flex;
align-items: center;
justify-content: center;
background: var(--color-room-mic-on);
color: #10331f;
cursor: pointer;
}
.room-pip-mic-toggle svg { width: 18px; height: 18px; }
.room-pip-btn svg { width: 18px; height: 18px; }
.room-pip-mic-toggle { background: var(--color-room-mic-on); color: #10331f; }
.room-pip-mic-toggle.is-off { background: var(--color-room-danger-bg); color: #fff; }
/* Демонстрация — «выключено» это нейтральное состояние (не тревога, в отличие
от снятого микрофона), поэтому неактивная кнопка тёмная под цвет плитки, а
активная подсвечена тем же зелёным, что включённый микрофон. */
.room-pip-share-toggle { background: var(--color-room-tile-hover); color: var(--color-room-text-primary); }
.room-pip-share-toggle.is-on { background: var(--color-room-mic-on); color: #10331f; }
/*
* ---------- Колонка миниатюр (кнопка «Скрыть» + карусель) ----------

View File

@@ -0,0 +1,124 @@
/*
* VidConf — выбор фона видео (сессия 35).
*
* Одна сетка плиток на три места: превью входа (`JoinPage`), комната
* (`BackgroundDialog`) и профиль. Базовые правила — на токенах оболочки;
* внутри комнаты те же классы перекрашиваются блоком `[data-theme="room"]`
* в конце файла, как это уже сделано для модалок (см. `room.css`,
* `.room-modal-*`): токены `--color-ink-*`/`--color-border` внутри комнаты
* не переопределяются, и без этого блока сетка была бы светлой на тёмном.
*/
.bg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: var(--space-3);
}
.bg-tile {
position: relative;
aspect-ratio: 16 / 9;
padding: 0;
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-alt);
overflow: hidden;
cursor: pointer;
display: block;
width: 100%;
}
.bg-tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.bg-tile:hover { border-color: var(--color-border-strong); }
.bg-tile.is-selected { border-color: var(--color-border-pill); }
.bg-tile:focus-visible { outline: 2px solid var(--color-border-pill); outline-offset: 2px; }
/* Плитка «без фона» — перечёркнутый круг вместо картинки. */
.bg-tile-none {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-ink-500);
}
.bg-tile-none .lucide { width: 20px; height: 20px; }
/* Метка выбранной плитки: галочка либо спиннер, пока фон применяется. */
.bg-tile-mark {
position: absolute;
right: 4px;
bottom: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--color-border-pill);
color: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
}
.bg-tile-mark .lucide { width: 14px; height: 14px; }
.bg-tile-spinner { animation: bg-tile-spin 1s linear infinite; }
@keyframes bg-tile-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.bg-tile-spinner { animation: none; }
}
/* Подсказка под сеткой — тот же тон, что у `.field-hint` оболочки. */
.bg-hint { font: var(--text-body); color: var(--color-ink-500); margin: var(--space-2) 0 0; }
/* Управление своими картинками (профиль): плитка + кнопка удаления поверх. */
.bg-manage-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: var(--space-3);
}
.bg-manage-tile {
position: relative;
aspect-ratio: 16 / 9;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-bg-alt);
}
.bg-manage-tile img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bg-tile-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.bg-tile-delete .lucide { width: 13px; height: 13px; }
.bg-tile-delete:hover { background: var(--color-danger); }
.bg-tile-delete:disabled { opacity: 0.6; cursor: default; }
/* ---------- Та же сетка внутри комнаты (тёмная тема) ---------- */
[data-theme='room'] .bg-tile {
border-color: var(--color-room-tile-border);
background: var(--color-room-tile);
}
[data-theme='room'] .bg-tile:hover { border-color: var(--color-room-text-secondary); }
[data-theme='room'] .bg-tile.is-selected { border-color: var(--color-room-speaker-ring); }
[data-theme='room'] .bg-tile:focus-visible { outline-color: var(--color-room-focus-ring); }
[data-theme='room'] .bg-tile-none { color: var(--color-room-text-secondary); }
[data-theme='room'] .bg-tile-mark {
background: var(--color-room-speaker-ring);
color: var(--color-room-bg);
}
[data-theme='room'] .bg-hint { color: var(--color-room-text-secondary); }

View File

@@ -1,11 +1,56 @@
import fs from 'node:fs'
import path from 'path'
import { defineConfig } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
/**
* Копирует wasm-рантайм MediaPipe (`@mediapipe/tasks-vision`) в `public/mediapipe/wasm/`,
* откуда его забирает замена фона (`lib/virtualBackground.ts`).
*
* Зачем вообще: библиотека `@livekit/track-processors` по умолчанию тянет и
* wasm, и модель сегментации с ВНЕШНИХ CDN (jsdelivr + storage.googleapis.com).
* VidConf ставят в закрытых контурах, где внешнего интернета нет, — там фича
* молча не заработала бы. Поэтому все ассеты отдаются со своего домена.
*
* Зачем копировать, а не коммитить: два варианта рантайма (simd/nosimd) весят
* ~19 МБ — в репозитории им не место, а версия обязана совпадать с
* установленной в `node_modules` (иначе рантайм и `vision_bundle.mjs`
* разъедутся). Копия делается и в dev (`vite`), и в сборке (`vite build`,
* который потом переносит весь `public/` в `dist/`), каталог назначения
* gitignored. Модель сегментации (`selfie_segmenter.tflite`, ~244 КБ) npm-пакетом
* не поставляется — она лежит в репозитории рядом, см. `public/mediapipe/NOTICE.txt`.
*/
function mediapipeWasm(): Plugin {
const source = path.resolve(__dirname, 'node_modules/@mediapipe/tasks-vision/wasm')
const target = path.resolve(__dirname, 'public/mediapipe/wasm')
return {
name: 'vidconf-mediapipe-wasm',
buildStart() {
if (!fs.existsSync(source)) {
// Пакет не установлен — падать сборкой не за что: замена фона просто
// не сможет инициализироваться, остальное приложение работает.
this.warn(`не найден wasm-рантайм MediaPipe: ${source}`)
return
}
fs.mkdirSync(target, { recursive: true })
for (const name of fs.readdirSync(source)) {
const from = path.join(source, name)
const to = path.join(target, name)
// Пропускаем уже скопированное с тем же размером — иначе каждый
// перезапуск dev-сервера переписывал бы 19 МБ.
const src = fs.statSync(from)
const dst = fs.existsSync(to) ? fs.statSync(to) : null
if (dst && dst.size === src.size) continue
fs.copyFileSync(from, to)
}
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react(), tailwindcss(), mediapipeWasm()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -19,6 +64,20 @@ export default defineConfig({
// WS-чат конференции ходит через тот же префикс /api
ws: true,
},
// Загруженные файлы (аватары, картинки фона). На проде их раздаёт nginx
// напрямую из тома (`location /media/`), минуя backend; в dev такого
// слоя нет, и без этого прокси все `/media/...` упирались бы в SPA
// и отдавали index.html вместо картинки.
//
// ⚠️ Ключ обязан быть со СЛЭШЕМ на конце. Vite матчит префиксом, и
// ключ `/media` перехватывал заодно `/mediapipe/...` — ассеты замены
// фона уходили на backend и получали 404, из-за чего фон молча не
// включался. У nginx та же семантика: там `location /media/` тоже
// со слэшем, поэтому прод этим не задет.
'/media/': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})