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 и фон молча не включался.
This commit is contained in:
2026-08-10 08:59:47 +03:00
parent fec9255baa
commit 17437880b1
37 changed files with 1918 additions and 18 deletions

7
.gitignore vendored
View File

@@ -54,3 +54,10 @@ backend/media/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) — # Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут. # привязаны к конкретной машине, в репозиторий не идут.
.claude/ .claude/
# Wasm-рантайм MediaPipe — копируется из node_modules плагином `mediapipeWasm`
# (frontend/vite.config.ts), ~19 МБ, в репозитории ему не место.
frontend/public/mediapipe/wasm/
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
frontend/bg-bench.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", "@fullcalendar/timegrid": "^6.1.21",
"@livekit/components-react": "^2.9.23", "@livekit/components-react": "^2.9.23",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1220,6 +1221,25 @@
"@bufbuild/protobuf": "^1.10.0" "@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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1921,6 +1941,23 @@
"license": "MIT", "license": "MIT",
"peer": true "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": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",

View File

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

@@ -54,6 +54,9 @@ export interface SettingsOut {
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на странице логина и в карточке «Как вас зовут?». */ * и превью камеры на странице логина и в карточке «Как вас зовут?». */
device_check_enabled: boolean device_check_enabled: boolean
/** Замена фона видео на картинку (сессия 35) — кнопка «Фон» в комнате,
* выбор фона в превью на входе и раздел «Свои фоны» в профиле. */
virtual_background_enabled: boolean
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
@@ -78,6 +81,7 @@ export interface SettingsUpdateIn {
consent_required?: boolean consent_required?: boolean
consent_policy_text?: string consent_policy_text?: string
device_check_enabled?: boolean device_check_enabled?: boolean
virtual_background_enabled?: boolean
} }
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

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

View File

@@ -6,6 +6,9 @@ export interface PublicSettingsOut {
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на LoginPage/JoinPage. */ * и превью камеры на LoginPage/JoinPage. */
device_check_enabled: boolean device_check_enabled: boolean
/** Замена фона видео (сессия 35) — нужен превью на JoinPage; участнику в
* комнате тот же флаг приезжает в join-ответе (`JoinOut`). */
virtual_background_enabled: boolean
} }
export async function getPublicSettings(): Promise<PublicSettingsOut> { export async function getPublicSettings(): Promise<PublicSettingsOut> {

View File

@@ -62,6 +62,42 @@ export async function deleteMyAvatar(): Promise<void> {
await apiRequest('/users/me/avatar', { method: 'DELETE' }) 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 { export interface PasswordChangePayload {
current_password: string current_password: string

View File

@@ -70,6 +70,9 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled) const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [deviceCheckEnabled, setDeviceCheckEnabled] = useState(data.device_check_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 [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level) const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients) const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -142,6 +145,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (deviceCheckEnabled !== data.device_check_enabled) payload.device_check_enabled = deviceCheckEnabled 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 (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -222,6 +227,25 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label> </label>
</div> </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-row">
<div className="toggle-copy"> <div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong> <strong>Транскрибация и суммаризация (AI)</strong>

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react' import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess' import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css' import '@/styles/device-check.css'
@@ -17,6 +18,13 @@ interface DeviceCheckCardProps {
audioEnabled: boolean audioEnabled: boolean
onToggleVideo: () => void onToggleVideo: () => void
onToggleAudio: () => void onToggleAudio: () => void
/**
* Выбор фона под кнопками (сессия 35) — приходит готовым узлом, а не набором
* пропсов: карточка про доступ к устройствам и знать про фоны, их лимиты и
* загрузку не обязана. `undefined` — модуль выключен, устройство не
* десктопное либо браузер не умеет сегментацию; тогда блока нет вовсе.
*/
backgroundPicker?: ReactNode
} }
/** /**
@@ -48,6 +56,7 @@ export function DeviceCheckCard({
audioEnabled, audioEnabled,
onToggleVideo, onToggleVideo,
onToggleAudio, onToggleAudio,
backgroundPicker,
}: DeviceCheckCardProps) { }: DeviceCheckCardProps) {
const showPlaceholder = videoStatus !== 'granted' || !videoEnabled const showPlaceholder = videoStatus !== 'granted' || !videoEnabled
return ( return (
@@ -86,6 +95,8 @@ export function DeviceCheckCard({
</div> </div>
</div> </div>
{backgroundPicker && <div className="device-check-backgrounds">{backgroundPicker}</div>}
{hint && <p className="field-hint device-check-hint">{hint}</p>} {hint && <p className="field-hint device-check-hint">{hint}</p>}
</div> </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 { import {
Hand, Hand,
Image,
LogOut, LogOut,
Maximize, Maximize,
MessageSquare, MessageSquare,
@@ -41,6 +42,15 @@ interface RoomToolbarProps extends StageViewProps {
onTogglePiP: () => void onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */ /** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean handQueueEnabled: boolean
/**
* Показывать ли кнопку «Фон». Уже учитывает ВСЁ сразу: включён ли модуль в
* админке, десктопное ли это устройство и умеет ли браузер сегментацию —
* см. `RoomPage`. При `false` кнопки нет вовсе (а не задизейбленной): на
* телефоне фичи не существует, и мёртвая кнопка там только мешает.
*/
backgroundVisible: boolean
backgroundOpen: boolean
onToggleBackground: () => void
/** /**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -100,6 +110,9 @@ export function RoomToolbar({
pipActive, pipActive,
onTogglePiP, onTogglePiP,
handQueueEnabled, handQueueEnabled,
backgroundVisible,
backgroundOpen,
onToggleBackground,
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
@@ -130,6 +143,9 @@ export function RoomToolbar({
2 /* микрофон, камера */ + 2 /* микрофон, камера */ +
(handQueueEnabled ? 2 : 0) /* рука, очередь */ + (handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ + 1 /* настройки */ +
// На телефоне всегда 0 (фича десктопная), но десктопное окно бывает и уже
// 600px — тогда кнопка «Фон» реально есть и должна попасть в подсчёт.
(backgroundVisible ? 1 : 0) +
(fullscreenSupported ? 1 : 0) + (fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) + (chatVisible ? 1 : 0) +
1 /* выйти */ 1 /* выйти */
@@ -244,6 +260,23 @@ export function RoomToolbar({
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span> <span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
</button> </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 && ( {fullscreenSupported && (
<button <button
type="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

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { usePersistentUserChoices } from '@livekit/components-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' export type DeviceCheckStatus = 'idle' | 'pending' | 'granted' | 'denied'
@@ -48,6 +50,13 @@ interface UseDeviceCheckAccessResult {
/** Переключить камеру — реально останавливает/перезапускает поток (см. выше). */ /** Переключить камеру — реально останавливает/перезапускает поток (см. выше). */
toggleVideoEnabled: () => void toggleVideoEnabled: () => void
toggleAudioEnabled: () => void toggleAudioEnabled: () => void
/**
* Выбранный фон применить не удалось — превью показывает «сырую» камеру.
* Нужен, чтобы сбой не был МОЛЧАЛИВЫМ: без этого флага пользователь видит
* выбранную плитку с галочкой и обычную картинку и решает, что фон просто
* не работает (ровно на это наступили на приёмке 0.0.35).
*/
backgroundFailed: boolean
} }
/** /**
@@ -78,7 +87,10 @@ interface UseDeviceCheckAccessResult {
* см. `RoomPage.tsx`); если сохранённого ID больше не существует * см. `RoomPage.tsx`); если сохранённого ID больше не существует
* (`OverconstrainedError`) — фолбэк на устройство по умолчанию системы. * (`OverconstrainedError`) — фолбэк на устройство по умолчанию системы.
*/ */
export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResult { export function useDeviceCheckAccess(
enabled: boolean,
backgroundUrl: string | null = null,
): UseDeviceCheckAccessResult {
const { userChoices } = usePersistentUserChoices() const { userChoices } = usePersistentUserChoices()
// Не в зависимостях эффектов/колбэков ниже — коллбэки живут в event-хендлерах // Не в зависимостях эффектов/колбэков ниже — коллбэки живут в event-хендлерах
// (жест), а не в реактивном дереве; актуальное значение достаточно иметь на // (жест), а не в реактивном дереве; актуальное значение достаточно иметь на
@@ -96,6 +108,11 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
const videoStreamRef = useRef<MediaStream | null>(null) const videoStreamRef = useRef<MediaStream | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null) const videoNodeRef = useRef<HTMLVideoElement | null>(null)
// Что реально показывается в `<video>`: либо сам поток камеры, либо поток с
// наложенным фоном. Отдельно от `videoStreamRef` — тот всегда остаётся
// «сырым» источником, который надо остановить при освобождении камеры
// (обработанный трек камеру не держит и сам её не выключит).
const displayStreamRef = useRef<MediaStream | null>(null)
// См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает // См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает
// уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого // уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого
// переход между шагами с превью терял бы картинку (но не поток — камера // переход между шагами с превью терял бы картинку (но не поток — камера
@@ -103,11 +120,53 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
const videoRef = useCallback((node: HTMLVideoElement | null) => { const videoRef = useCallback((node: HTMLVideoElement | null) => {
videoNodeRef.current = node videoNodeRef.current = node
if (node) { if (node) {
node.srcObject = videoStreamRef.current node.srcObject = displayStreamRef.current ?? videoStreamRef.current
} }
}, []) }, [])
const requestedRef = useRef(false) 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()
}, [])
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана // Полный сброс — не только остановка треков, но и статусы/флаги/охрана
// повторного запроса. Нужен и на «настоящем» уходе (сабмит/размонтирование), // повторного запроса. Нужен и на «настоящем» уходе (сабмит/размонтирование),
// и на возврате к этой же карточке В ПРЕДЕЛАХ одного монтирования хука // и на возврате к этой же карточке В ПРЕДЕЛАХ одного монтирования хука
@@ -115,20 +174,20 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
// без сброса `requestedRef` повторный заход не переспросил бы доступ и // без сброса `requestedRef` повторный заход не переспросил бы доступ и
// навсегда остался бы с потухшим превью при formально «granted» статусе. // навсегда остался бы с потухшим превью при formально «granted» статусе.
const release = useCallback(() => { const release = useCallback(() => {
destroyProcessor()
const stream = videoStreamRef.current const stream = videoStreamRef.current
if (stream) { if (stream) {
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null videoStreamRef.current = null
} }
if (videoNodeRef.current) { showStream(null)
videoNodeRef.current.srcObject = null
}
requestedRef.current = false requestedRef.current = false
setVideoStatus('idle') setVideoStatus('idle')
setAudioStatus('idle') setAudioStatus('idle')
setVideoEnabled(false) setVideoEnabled(false)
setAudioEnabled(false) setAudioEnabled(false)
}, []) setBackgroundFailed(false)
}, [destroyProcessor, showStream])
// Размонтирование карточки — последний рубеж освобождения камеры: даже // Размонтирование карточки — последний рубеж освобождения камеры: даже
// если вызывающая сторона забудет свой release() на каком-то из путей // если вызывающая сторона забудет свой release() на каком-то из путей
@@ -144,9 +203,10 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
try { try {
const stream = await openStream('video', userChoicesRef.current.videoDeviceId) const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
videoStreamRef.current = stream videoStreamRef.current = stream
if (videoNodeRef.current) { // Сначала показываем «сырой» поток — картинка появляется сразу, а фон
videoNodeRef.current.srcObject = stream // (если выбран) наложится следом, когда доедет модель.
} showStream(stream)
setVideoSourceVersion((version) => version + 1)
setVideoStatus('granted') setVideoStatus('granted')
// Первичная верификация сразу показывает превью — «включено» по факту // Первичная верификация сразу показывает превью — «включено» по факту
// получения потока, а не отдельным действием пользователя. // получения потока, а не отдельным действием пользователя.
@@ -154,7 +214,7 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
} catch { } catch {
setVideoStatus('denied') setVideoStatus('denied')
} }
}, []) }, [showStream])
// Кнопка камеры реально управляет потоком — выключение останавливает // Кнопка камеры реально управляет потоком — выключение останавливает
// треки (индикатор камеры гаснет, ровно то, ради чего вся фича), включение // треки (индикатор камеры гаснет, ровно то, ради чего вся фича), включение
@@ -164,19 +224,77 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
// делся, остановлен только сам поток. // делся, остановлен только сам поток.
const toggleVideoEnabled = useCallback(() => { const toggleVideoEnabled = useCallback(() => {
if (videoEnabled) { if (videoEnabled) {
destroyProcessor()
const stream = videoStreamRef.current const stream = videoStreamRef.current
if (stream) { if (stream) {
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null videoStreamRef.current = null
} }
if (videoNodeRef.current) { showStream(null)
videoNodeRef.current.srcObject = null setVideoSourceVersion((version) => version + 1)
}
setVideoEnabled(false) setVideoEnabled(false)
return return
} }
void requestVideo() void requestVideo()
}, [videoEnabled, 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 () => { const requestAudio = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
@@ -250,6 +368,7 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
audioEnabled, audioEnabled,
toggleVideoEnabled, toggleVideoEnabled,
toggleAudioEnabled, toggleAudioEnabled,
backgroundFailed,
} }
} }

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,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,173 @@
/**
* Замена фона видео: список дефолтных сцен, память о выборе и ленивое создание
* процессора сегментации.
*
* Тяжёлая часть (библиотека + 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
}
/**
* Поддерживает ли браузер сегментацию вообще.
*
* Проверяется по наличию API конвейера обработки кадров (Insertable Streams /
* `MediaStreamTrackProcessor`) — их нет, например, в Firefox. Проверка
* СИНХРОННАЯ и намеренно не трогает саму библиотеку: она нужна до её загрузки,
* чтобы не показывать кнопку, которая всё равно не сработает, и не тянуть
* мегабайты впустую.
*/
export function isVirtualBackgroundSupported(): boolean {
if (typeof window === 'undefined') return false
return (
'MediaStreamTrackProcessor' in window &&
'MediaStreamTrackGenerator' in window &&
typeof OffscreenCanvas !== 'undefined'
)
}
/**
* Ассеты 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

@@ -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 { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react' import { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
import { import {
@@ -14,6 +14,16 @@ import { DeviceCheckCard } from '@/components/auth/DeviceCheckCard'
import { LogoMark } from '@/components/ui/LogoMark' import { LogoMark } from '@/components/ui/LogoMark'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { useDeviceCheckAccess } from '@/hooks/useDeviceCheckAccess' 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 { usePublicSettings } from '@/hooks/usePublicSettings'
import { parseJoinQuery } from '@/lib/parseJoinQuery' import { parseJoinQuery } from '@/lib/parseJoinQuery'
import '@/styles/join.css' import '@/styles/join.css'
@@ -92,7 +102,32 @@ export function JoinPage() {
// перезахватывается (что вдобавок раскрутило бы известную грабли с // перезахватывается (что вдобавок раскрутило бы известную грабли с
// deviceId `"default"`, см. `useDeviceCheckAccess.ts`). // deviceId `"default"`, см. `useDeviceCheckAccess.ts`).
const inDeviceCheckSteps = deviceCheckModuleEnabled && (step === 'input' || step === 'guest-info') const inDeviceCheckSteps = deviceCheckModuleEnabled && (step === 'input' || step === 'guest-info')
const deviceCheck = useDeviceCheckAccess(inDeviceCheckSteps)
// Замена фона (сессия 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` целиком // Деструктурируем то, что нужно эффекту ниже, — `deviceCheck` целиком
// пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по // пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по
// себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект // себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект
@@ -198,6 +233,7 @@ export function JoinPage() {
handQueueEnabled: data.hand_queue_enabled, handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap, publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles, stageMaxTiles: data.stage_max_tiles,
virtualBackgroundEnabled: data.virtual_background_enabled,
// Кнопки превью «войти с камерой/микрофоном» — теперь доступны и // Кнопки превью «войти с камерой/микрофоном» — теперь доступны и
// гостю (input → guest-info → сюда), и авторизованному пользователю // гостю (input → guest-info → сюда), и авторизованному пользователю
// (input → connecting → сюда, guest-info он не проходит вовсе, см. // (input → connecting → сюда, guest-info он не проходит вовсе, см.
@@ -287,6 +323,20 @@ export function JoinPage() {
audioEnabled={deviceCheck.audioEnabled} audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled} onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled} 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"> <div className="flow-icon" aria-hidden="true">
@@ -357,6 +407,20 @@ export function JoinPage() {
audioEnabled={deviceCheck.audioEnabled} audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled} onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled} 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"> <div className="flow-icon" aria-hidden="true">

View File

@@ -46,6 +46,7 @@ export function LobbyPage() {
handQueueEnabled: conference.join.hand_queue_enabled, handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap, publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles, stageMaxTiles: conference.join.stage_max_tiles,
virtualBackgroundEnabled: conference.join.virtual_background_enabled,
number: conference.number, number: conference.number,
slug: conference.slug, slug: conference.slug,
}, },

View File

@@ -8,6 +8,8 @@ import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users' 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 { ApiError, errorDetail } from '@/api/client'
import '@/styles/lobby.css' import '@/styles/lobby.css'
import '@/styles/profile.css' import '@/styles/profile.css'
@@ -38,6 +40,11 @@ export function ProfilePage() {
const [avatarError, setAvatarError] = useState<string | null>(null) const [avatarError, setAvatarError] = useState<string | null>(null)
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams }) 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 })))] const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
async function afterProfileChange() { async function afterProfileChange() {
@@ -216,6 +223,8 @@ export function ProfilePage() {
</form> </form>
</section> </section>
{publicSettings?.virtual_background_enabled && <MyBackgroundsSection />}
<section className="profile-card"> <section className="profile-card">
<h2>Смена пароля</h2> <h2>Смена пароля</h2>
<form onSubmit={handlePasswordSubmit}> <form onSubmit={handlePasswordSubmit}>

View File

@@ -12,12 +12,15 @@ import { useAuth } from '@/auth/useAuth'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { useFullscreen } from '@/hooks/useFullscreen' import { useFullscreen } from '@/hooks/useFullscreen'
import { useRoomPiP } from '@/hooks/useRoomPiP' import { useRoomPiP } from '@/hooks/useRoomPiP'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import { isVirtualBackgroundSupported } from '@/lib/virtualBackground'
import { RoomTopbar } from '@/components/room/RoomTopbar' import { RoomTopbar } from '@/components/room/RoomTopbar'
import { RoomStage } from '@/components/room/RoomStage' import { RoomStage } from '@/components/room/RoomStage'
import { RoomToolbar } from '@/components/room/RoomToolbar' import { RoomToolbar } from '@/components/room/RoomToolbar'
import { ChatPanel } from '@/components/room/ChatPanel' import { ChatPanel } from '@/components/room/ChatPanel'
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher' import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog' import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
import { VirtualBackgroundController } from '@/components/room/VirtualBackgroundController'
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice' import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
import { buildPublishDefaults } from '@/lib/publishQualityCap' import { buildPublishDefaults } from '@/lib/publishQualityCap'
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode' import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
@@ -69,6 +72,8 @@ interface RoomJoinState {
*/ */
publishQualityCap?: PublishQualityCap publishQualityCap?: PublishQualityCap
stageMaxTiles?: number stageMaxTiles?: number
/** `JoinOut.virtual_background_enabled` — при `false` кнопки «Фон» нет вовсе. */
virtualBackgroundEnabled?: boolean
/** /**
* Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на * Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на
* входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→ * входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→
@@ -222,6 +227,7 @@ export function RoomPage() {
handQueueEnabled: result.hand_queue_enabled, handQueueEnabled: result.hand_queue_enabled,
publishQualityCap: result.publish_quality_cap, publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles, stageMaxTiles: result.stage_max_tiles,
virtualBackgroundEnabled: result.virtual_background_enabled,
}) })
} }
} catch (err) { } catch (err) {
@@ -290,6 +296,20 @@ export function RoomPage() {
const fullscreen = useFullscreen(roomRootRef) const fullscreen = useFullscreen(roomRootRef)
const pip = useRoomPiP(roomRootRef) const pip = useRoomPiP(roomRootRef)
const [settingsOpen, setSettingsOpen] = useState(false) 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), а не часть потока: показываются // `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
@@ -547,6 +567,9 @@ export function RoomPage() {
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
handQueueEnabled={Boolean(joinState.handQueueEnabled)} handQueueEnabled={Boolean(joinState.handQueueEnabled)}
backgroundVisible={backgroundVisible}
backgroundOpen={backgroundOpen}
onToggleBackground={() => setBackgroundOpen((open) => !open)}
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}
@@ -559,6 +582,15 @@ export function RoomPage() {
onLeave={handleLeaveClick} onLeave={handleLeaveClick}
/> />
</div> </div>
{/* Смонтирован всё время, пока модуль включён, — фон живёт независимо
от того, открыто ли окно выбора (см. докстринг компонента). */}
{backgroundVisible && (
<VirtualBackgroundController
open={backgroundOpen}
onClose={() => setBackgroundOpen(false)}
canManageOwn={status === 'authenticated'}
/>
)}
{settingsOpen && ( {settingsOpen && (
<DeviceSettingsDialog <DeviceSettingsDialog
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}

View File

@@ -73,3 +73,23 @@
text-align: center; text-align: center;
margin-top: var(--space-3); 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,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 path from 'path'
import { defineConfig } from 'vite' import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss(), mediapipeWasm()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
@@ -19,6 +64,20 @@ export default defineConfig({
// WS-чат конференции ходит через тот же префикс /api // WS-чат конференции ходит через тот же префикс /api
ws: true, 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,
},
}, },
}, },
}) })