@@ -357,6 +407,20 @@ export function JoinPage() {
audioEnabled={deviceCheck.audioEnabled}
onToggleVideo={deviceCheck.toggleVideoEnabled}
onToggleAudio={deviceCheck.toggleAudioEnabled}
+ backgroundPicker={
+ backgroundPickerVisible ? (
+ <>
+
+ {deviceCheck.backgroundFailed && (
+
Не удалось применить фон в превью
+ )}
+ >
+ ) : undefined
+ }
/>
) : (
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx
index 66517b0..2cce3d1 100644
--- a/frontend/src/pages/LobbyPage.tsx
+++ b/frontend/src/pages/LobbyPage.tsx
@@ -46,6 +46,7 @@ export function LobbyPage() {
handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles,
+ virtualBackgroundEnabled: conference.join.virtual_background_enabled,
number: conference.number,
slug: conference.slug,
},
diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx
index ecf102f..5cd25b3 100644
--- a/frontend/src/pages/ProfilePage.tsx
+++ b/frontend/src/pages/ProfilePage.tsx
@@ -8,6 +8,8 @@ import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
import { useAuth } from '@/auth/useAuth'
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users'
+import { MyBackgroundsSection } from '@/components/background/MyBackgroundsSection'
+import { usePublicSettings } from '@/hooks/usePublicSettings'
import { ApiError, errorDetail } from '@/api/client'
import '@/styles/lobby.css'
import '@/styles/profile.css'
@@ -38,6 +40,11 @@ export function ProfilePage() {
const [avatarError, setAvatarError] = useState(null)
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
+ // Раздел «Свои фоны» есть, только пока модуль включён в админке — иначе
+ // картинкам, которые никак не применить, в профиле не место (то же правило,
+ // что и с кнопкой «Фон» в комнате: выключенный модуль не оставляет мёртвых
+ // элементов интерфейса).
+ const { data: publicSettings } = usePublicSettings()
const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
async function afterProfileChange() {
@@ -216,6 +223,8 @@ export function ProfilePage() {
+ {publicSettings?.virtual_background_enabled && }
+
+ {/* Смонтирован всё время, пока модуль включён, — фон живёт независимо
+ от того, открыто ли окно выбора (см. докстринг компонента). */}
+ {backgroundVisible && (
+
setBackgroundOpen(false)}
+ canManageOwn={status === 'authenticated'}
+ />
+ )}
{settingsOpen && (
setSettingsOpen(false)}
diff --git a/frontend/src/styles/device-check.css b/frontend/src/styles/device-check.css
index 422dd85..7e8dc48 100644
--- a/frontend/src/styles/device-check.css
+++ b/frontend/src/styles/device-check.css
@@ -73,3 +73,23 @@
text-align: center;
margin-top: var(--space-3);
}
+
+/* Выбор фона под кнопками превью (сессия 35).
+ Здесь плитки идут ОДНОЙ прокручиваемой строкой, а не сеткой, как в комнате
+ и в профиле: карточка входа и без того высокая, а число своих картинок
+ доходит до десяти — сетка выросла бы на шесть рядов и увела бы кнопку
+ «Продолжить» за нижний край экрана. Строка держит высоту постоянной при
+ любом числе картинок. Сами плитки — общие, см. virtual-background.css. */
+.device-check-backgrounds {
+ margin: var(--space-4) auto 0;
+}
+.device-check-backgrounds .bg-grid {
+ display: flex;
+ gap: var(--space-2);
+ overflow-x: auto;
+ padding-bottom: var(--space-2);
+}
+.device-check-backgrounds .bg-tile {
+ flex: 0 0 auto;
+ width: 72px;
+}
diff --git a/frontend/src/styles/virtual-background.css b/frontend/src/styles/virtual-background.css
new file mode 100644
index 0000000..0cf94bb
--- /dev/null
+++ b/frontend/src/styles/virtual-background.css
@@ -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); }
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 56d9ddc..570c603 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,11 +1,56 @@
+import fs from 'node:fs'
import path from 'path'
-import { defineConfig } from 'vite'
+import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
+/**
+ * Копирует wasm-рантайм MediaPipe (`@mediapipe/tasks-vision`) в `public/mediapipe/wasm/`,
+ * откуда его забирает замена фона (`lib/virtualBackground.ts`).
+ *
+ * Зачем вообще: библиотека `@livekit/track-processors` по умолчанию тянет и
+ * wasm, и модель сегментации с ВНЕШНИХ CDN (jsdelivr + storage.googleapis.com).
+ * VidConf ставят в закрытых контурах, где внешнего интернета нет, — там фича
+ * молча не заработала бы. Поэтому все ассеты отдаются со своего домена.
+ *
+ * Зачем копировать, а не коммитить: два варианта рантайма (simd/nosimd) весят
+ * ~19 МБ — в репозитории им не место, а версия обязана совпадать с
+ * установленной в `node_modules` (иначе рантайм и `vision_bundle.mjs`
+ * разъедутся). Копия делается и в dev (`vite`), и в сборке (`vite build`,
+ * который потом переносит весь `public/` в `dist/`), каталог назначения
+ * gitignored. Модель сегментации (`selfie_segmenter.tflite`, ~244 КБ) npm-пакетом
+ * не поставляется — она лежит в репозитории рядом, см. `public/mediapipe/NOTICE.txt`.
+ */
+function mediapipeWasm(): Plugin {
+ const source = path.resolve(__dirname, 'node_modules/@mediapipe/tasks-vision/wasm')
+ const target = path.resolve(__dirname, 'public/mediapipe/wasm')
+ return {
+ name: 'vidconf-mediapipe-wasm',
+ buildStart() {
+ if (!fs.existsSync(source)) {
+ // Пакет не установлен — падать сборкой не за что: замена фона просто
+ // не сможет инициализироваться, остальное приложение работает.
+ this.warn(`не найден wasm-рантайм MediaPipe: ${source}`)
+ return
+ }
+ fs.mkdirSync(target, { recursive: true })
+ for (const name of fs.readdirSync(source)) {
+ const from = path.join(source, name)
+ const to = path.join(target, name)
+ // Пропускаем уже скопированное с тем же размером — иначе каждый
+ // перезапуск dev-сервера переписывал бы 19 МБ.
+ const src = fs.statSync(from)
+ const dst = fs.existsSync(to) ? fs.statSync(to) : null
+ if (dst && dst.size === src.size) continue
+ fs.copyFileSync(from, to)
+ }
+ },
+ }
+}
+
// https://vite.dev/config/
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ plugins: [react(), tailwindcss(), mediapipeWasm()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -19,6 +64,20 @@ export default defineConfig({
// WS-чат конференции ходит через тот же префикс /api
ws: true,
},
+ // Загруженные файлы (аватары, картинки фона). На проде их раздаёт nginx
+ // напрямую из тома (`location /media/`), минуя backend; в dev такого
+ // слоя нет, и без этого прокси все `/media/...` упирались бы в SPA
+ // и отдавали index.html вместо картинки.
+ //
+ // ⚠️ Ключ обязан быть со СЛЭШЕМ на конце. Vite матчит префиксом, и
+ // ключ `/media` перехватывал заодно `/mediapipe/...` — ассеты замены
+ // фона уходили на backend и получали 404, из-за чего фон молча не
+ // включался. У nginx та же семантика: там `location /media/` тоже
+ // со слэшем, поэтому прод этим не задет.
+ '/media/': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
},
},
})