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:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
33
design/backgrounds/README.md
Normal file
33
design/backgrounds/README.md
Normal 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 пересобирается из них, вручную растровые файлы не трогаем.
|
||||||
106
design/backgrounds/beach.svg
Normal file
106
design/backgrounds/beach.svg
Normal 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 |
130
design/backgrounds/office.svg
Normal file
130
design/backgrounds/office.svg
Normal 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 |
73
design/backgrounds/render.py
Normal file
73
design/backgrounds/render.py
Normal 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())
|
||||||
150
design/backgrounds/space-station.svg
Normal file
150
design/backgrounds/space-station.svg
Normal 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 |
37
frontend/package-lock.json
generated
37
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
frontend/public/backgrounds/beach.webp
Normal file
BIN
frontend/public/backgrounds/beach.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/backgrounds/office.webp
Normal file
BIN
frontend/public/backgrounds/office.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/public/backgrounds/space-station.webp
Normal file
BIN
frontend/public/backgrounds/space-station.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
16
frontend/public/mediapipe/NOTICE.txt
Normal file
16
frontend/public/mediapipe/NOTICE.txt
Normal 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.
|
||||||
BIN
frontend/public/mediapipe/selfie_segmenter.tflite
Normal file
BIN
frontend/public/mediapipe/selfie_segmenter.tflite
Normal file
Binary file not shown.
@@ -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`). */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
73
frontend/src/components/background/BackgroundPicker.tsx
Normal file
73
frontend/src/components/background/BackgroundPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
frontend/src/components/background/MyBackgroundsSection.tsx
Normal file
143
frontend/src/components/background/MyBackgroundsSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/room/BackgroundDialog.tsx
Normal file
77
frontend/src/components/room/BackgroundDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
46
frontend/src/components/room/VirtualBackgroundController.tsx
Normal file
46
frontend/src/components/room/VirtualBackgroundController.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
frontend/src/hooks/useMyBackgrounds.ts
Normal file
26
frontend/src/hooks/useMyBackgrounds.ts
Normal 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
|
||||||
|
}
|
||||||
155
frontend/src/hooks/useVirtualBackground.ts
Normal file
155
frontend/src/hooks/useVirtualBackground.ts
Normal 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 }
|
||||||
|
}
|
||||||
74
frontend/src/lib/imageResize.ts
Normal file
74
frontend/src/lib/imageResize.ts
Normal 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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
39
frontend/src/lib/isDesktopDevice.ts
Normal file
39
frontend/src/lib/isDesktopDevice.ts
Normal 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
|
||||||
|
}
|
||||||
173
frontend/src/lib/virtualBackground.ts
Normal file
173
frontend/src/lib/virtualBackground.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
124
frontend/src/styles/virtual-background.css
Normal file
124
frontend/src/styles/virtual-background.css
Normal 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); }
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user