diff --git a/.gitignore b/.gitignore index 15de071..795daa5 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,10 @@ backend/media/ # Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) — # привязаны к конкретной машине, в репозиторий не идут. .claude/ + +# Wasm-рантайм MediaPipe — копируется из node_modules плагином `mediapipeWasm` +# (frontend/vite.config.ts), ~19 МБ, в репозитории ему не место. +frontend/public/mediapipe/wasm/ + +# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт. +frontend/bg-bench.html diff --git a/design/backgrounds/README.md b/design/backgrounds/README.md new file mode 100644 index 0000000..0b3de4a --- /dev/null +++ b/design/backgrounds/README.md @@ -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 пересобирается из них, вручную растровые файлы не трогаем. diff --git a/design/backgrounds/beach.svg b/design/backgrounds/beach.svg new file mode 100644 index 0000000..45df784 --- /dev/null +++ b/design/backgrounds/beach.svg @@ -0,0 +1,106 @@ + + Пляж с пальмами — дефолтный фон замены фона видео + Собственная векторная сцена VidConf. Море, песок и пальмы по краям кадра; + центр оставлен спокойным — там находится человек. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/backgrounds/office.svg b/design/backgrounds/office.svg new file mode 100644 index 0000000..b5a8cd7 --- /dev/null +++ b/design/backgrounds/office.svg @@ -0,0 +1,130 @@ + + Офис — дефолтный фон замены фона видео + Собственная векторная сцена VidConf. Светлый переговорный угол: окно с + тёплым светом, стеллаж с книгами и растениями. Центр кадра намеренно спокойный — + там будет человек, детали вынесены к краям. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/design/backgrounds/render.py b/design/backgrounds/render.py new file mode 100644 index 0000000..b2ace16 --- /dev/null +++ b/design/backgrounds/render.py @@ -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()) diff --git a/design/backgrounds/space-station.svg b/design/backgrounds/space-station.svg new file mode 100644 index 0000000..3975a0e --- /dev/null +++ b/design/backgrounds/space-station.svg @@ -0,0 +1,150 @@ + + Космическая станция — дефолтный фон замены фона видео + Собственная векторная сцена VidConf. Интерьер модуля станции: панели, + подсветка, иллюминатор с Землёй. Центр кадра спокойный — там человек. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3c2b806..f0996a7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@fullcalendar/timegrid": "^6.1.21", "@livekit/components-react": "^2.9.23", "@livekit/components-styles": "^1.2.0", + "@livekit/track-processors": "^0.7.2", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", @@ -1220,6 +1221,25 @@ "@bufbuild/protobuf": "^1.10.0" } }, + "node_modules/@livekit/track-processors": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz", + "integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==", + "license": "Apache-2.0", + "dependencies": { + "@mediapipe/tasks-vision": "0.10.14" + }, + "peerDependencies": { + "@types/dom-mediacapture-transform": "^0.1.9", + "livekit-client": "^1.12.0 || ^2.1.0" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz", + "integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==", + "license": "Apache-2.0" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -1921,6 +1941,23 @@ "license": "MIT", "peer": true }, + "node_modules/@types/dom-mediacapture-transform": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz", + "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/dom-webcodecs": "*" + } + }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.18", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz", + "integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==", + "license": "MIT", + "peer": true + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95d6efc..53eb134 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@fullcalendar/timegrid": "^6.1.21", "@livekit/components-react": "^2.9.23", "@livekit/components-styles": "^1.2.0", + "@livekit/track-processors": "^0.7.2", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", "class-variance-authority": "^0.7.1", diff --git a/frontend/public/backgrounds/beach.webp b/frontend/public/backgrounds/beach.webp new file mode 100644 index 0000000..1fcdaaf Binary files /dev/null and b/frontend/public/backgrounds/beach.webp differ diff --git a/frontend/public/backgrounds/office.webp b/frontend/public/backgrounds/office.webp new file mode 100644 index 0000000..1aac7b4 Binary files /dev/null and b/frontend/public/backgrounds/office.webp differ diff --git a/frontend/public/backgrounds/space-station.webp b/frontend/public/backgrounds/space-station.webp new file mode 100644 index 0000000..76b6aaf Binary files /dev/null and b/frontend/public/backgrounds/space-station.webp differ diff --git a/frontend/public/mediapipe/NOTICE.txt b/frontend/public/mediapipe/NOTICE.txt new file mode 100644 index 0000000..0a34c57 --- /dev/null +++ b/frontend/public/mediapipe/NOTICE.txt @@ -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. diff --git a/frontend/public/mediapipe/selfie_segmenter.tflite b/frontend/public/mediapipe/selfie_segmenter.tflite new file mode 100644 index 0000000..a4ebd47 Binary files /dev/null and b/frontend/public/mediapipe/selfie_segmenter.tflite differ diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 898eaa0..cf608c9 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -54,6 +54,9 @@ export interface SettingsOut { /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону * и превью камеры на странице логина и в карточке «Как вас зовут?». */ device_check_enabled: boolean + /** Замена фона видео на картинку (сессия 35) — кнопка «Фон» в комнате, + * выбор фона в превью на входе и раздел «Свои фоны» в профиле. */ + virtual_background_enabled: boolean } /** Тело частичного обновления настроек инстанса — все поля опциональны. */ @@ -78,6 +81,7 @@ export interface SettingsUpdateIn { consent_required?: boolean consent_policy_text?: string device_check_enabled?: boolean + virtual_background_enabled?: boolean } /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ diff --git a/frontend/src/api/conferences.ts b/frontend/src/api/conferences.ts index 64638ca..335bbf4 100644 --- a/frontend/src/api/conferences.ts +++ b/frontend/src/api/conferences.ts @@ -67,6 +67,8 @@ export interface ConferenceJoinData { publish_quality_cap: PublishQualityCap /** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */ stage_max_tiles: number + /** Включён ли модуль «замена фона» — при `false` кнопка «Фон» в тулбаре не рендерится. */ + virtual_background_enabled: boolean } /** diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 1ac5be9..9470c41 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -6,6 +6,9 @@ export interface PublicSettingsOut { /** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону * и превью камеры на LoginPage/JoinPage. */ device_check_enabled: boolean + /** Замена фона видео (сессия 35) — нужен превью на JoinPage; участнику в + * комнате тот же флаг приезжает в join-ответе (`JoinOut`). */ + virtual_background_enabled: boolean } export async function getPublicSettings(): Promise { diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index ea3a4f9..be94378 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -62,6 +62,42 @@ export async function deleteMyAvatar(): Promise { 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 { + return apiRequest('/users/me/backgrounds') +} + +/** + * Загрузить свою картинку фона; возвращается весь список заново. + * + * Картинка ужимается ДО отправки (`lib/imageResize.ts`) — сервер её не + * пережимает (Pillow на backend нет). Коды ошибок: 409 — упёрлись в лимит, + * 413 — файл больше 2 МБ, 415 — недопустимый тип. + */ +export async function uploadMyBackground(file: File | Blob): Promise { + const form = new FormData() + form.append('file', file, 'background.webp') + return apiRequest('/users/me/backgrounds', { method: 'POST', body: form }) +} + +/** Удалить свою картинку фона — 204 без тела; файл на диске удаляется вместе с записью. */ +export async function deleteMyBackground(id: string): Promise { + await apiRequest(`/users/me/backgrounds/${id}`, { method: 'DELETE' }) +} + /** Тело смены пароля текущего пользователя. */ export interface PasswordChangePayload { current_password: string diff --git a/frontend/src/components/admin/AdminSettingsTab.tsx b/frontend/src/components/admin/AdminSettingsTab.tsx index eeccb78..38cfcdb 100644 --- a/frontend/src/components/admin/AdminSettingsTab.tsx +++ b/frontend/src/components/admin/AdminSettingsTab.tsx @@ -70,6 +70,9 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled) const [deviceCheckEnabled, setDeviceCheckEnabled] = useState(data.device_check_enabled) + const [virtualBackgroundEnabled, setVirtualBackgroundEnabled] = useState( + data.virtual_background_enabled, + ) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiLevel, setAiLevel] = useState(data.ai_level) const [recipients, setRecipients] = useState(data.summary_recipients) @@ -142,6 +145,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled if (deviceCheckEnabled !== data.device_check_enabled) payload.device_check_enabled = deviceCheckEnabled + if (virtualBackgroundEnabled !== data.virtual_background_enabled) + payload.virtual_background_enabled = virtualBackgroundEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (recipients !== data.summary_recipients) payload.summary_recipients = recipients @@ -222,6 +227,25 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) { +
+
+ Замена фона видео + + Выбор фона в конференции: три готовые сцены и свои картинки (до 10 штук, + загружаются в профиле). Работает только на компьютере — фон считает нейросеть + на каждом кадре, и на телефоне это греет устройство и просаживает встречу + +
+ +
+
Транскрибация и суммаризация (AI) diff --git a/frontend/src/components/auth/DeviceCheckCard.tsx b/frontend/src/components/auth/DeviceCheckCard.tsx index 79e54e0..4233515 100644 --- a/frontend/src/components/auth/DeviceCheckCard.tsx +++ b/frontend/src/components/auth/DeviceCheckCard.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { Mic, MicOff, Video, VideoOff } from 'lucide-react' import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess' import '@/styles/device-check.css' @@ -17,6 +18,13 @@ interface DeviceCheckCardProps { audioEnabled: boolean onToggleVideo: () => void onToggleAudio: () => void + /** + * Выбор фона под кнопками (сессия 35) — приходит готовым узлом, а не набором + * пропсов: карточка про доступ к устройствам и знать про фоны, их лимиты и + * загрузку не обязана. `undefined` — модуль выключен, устройство не + * десктопное либо браузер не умеет сегментацию; тогда блока нет вовсе. + */ + backgroundPicker?: ReactNode } /** @@ -48,6 +56,7 @@ export function DeviceCheckCard({ audioEnabled, onToggleVideo, onToggleAudio, + backgroundPicker, }: DeviceCheckCardProps) { const showPlaceholder = videoStatus !== 'granted' || !videoEnabled return ( @@ -86,6 +95,8 @@ export function DeviceCheckCard({
+ {backgroundPicker &&
{backgroundPicker}
} + {hint &&

{hint}

} ) diff --git a/frontend/src/components/background/BackgroundPicker.tsx b/frontend/src/components/background/BackgroundPicker.tsx new file mode 100644 index 0000000..dd611c0 --- /dev/null +++ b/frontend/src/components/background/BackgroundPicker.tsx @@ -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 ( + + ) + } + + return ( +
+ {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), + )} +
+ ) +} diff --git a/frontend/src/components/background/MyBackgroundsSection.tsx b/frontend/src/components/background/MyBackgroundsSection.tsx new file mode 100644 index 0000000..4009474 --- /dev/null +++ b/frontend/src/components/background/MyBackgroundsSection.tsx @@ -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 не стало, + * `useVirtualBackground` сам сбрасывает фон на «без фона». + */ +export function MyBackgroundsSection() { + const toast = useToast() + const queryClient = useQueryClient() + const fileInputRef = useRef(null) + const [error, setError] = useState(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 ( +
+

Свои фоны

+

+ Картинки для замены фона видео в конференции. Не больше {limit || 10} штук; перед + отправкой картинка автоматически уменьшается. + {!desktop && ' Сама замена фона работает только на компьютере — на телефоне она' + + ' отключена, чтобы не нагружать устройство.'} +

+ + {items.length > 0 && ( +
+ {items.map((item, index) => ( +
+ {`Свой + +
+ ))} +
+ )} + +
+ + + {items.length} из {limit || 10} + + { + const file = e.target.files?.[0] ?? null + e.target.value = '' + setError(null) + if (file) uploadMutation.mutate(file) + }} + /> +
+ + {error && ( +

+ {error} +

+ )} +
+ ) +} diff --git a/frontend/src/components/room/BackgroundDialog.tsx b/frontend/src/components/room/BackgroundDialog.tsx new file mode 100644 index 0000000..6d3d1a6 --- /dev/null +++ b/frontend/src/components/room/BackgroundDialog.tsx @@ -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 ( +
+
e.stopPropagation()}> +
+

Фон

+ +
+ + + + {status === 'error' ? ( +

+ Не удалось применить фон — попробуйте выбрать другой или отключить фон. +

+ ) : ( +

+ {canManageOwn + ? 'Свои картинки добавляются в профиле — их видно здесь сразу после загрузки.' + : 'Свои картинки доступны зарегистрированным пользователям.'} +

+ )} +
+
+ ) +} diff --git a/frontend/src/components/room/RoomToolbar.tsx b/frontend/src/components/room/RoomToolbar.tsx index c463f41..7dab431 100644 --- a/frontend/src/components/room/RoomToolbar.tsx +++ b/frontend/src/components/room/RoomToolbar.tsx @@ -1,5 +1,6 @@ import { Hand, + Image, LogOut, Maximize, MessageSquare, @@ -41,6 +42,15 @@ interface RoomToolbarProps extends StageViewProps { onTogglePiP: () => void /** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */ handQueueEnabled: boolean + /** + * Показывать ли кнопку «Фон». Уже учитывает ВСЁ сразу: включён ли модуль в + * админке, десктопное ли это устройство и умеет ли браузер сегментацию — + * см. `RoomPage`. При `false` кнопки нет вовсе (а не задизейбленной): на + * телефоне фичи не существует, и мёртвая кнопка там только мешает. + */ + backgroundVisible: boolean + backgroundOpen: boolean + onToggleBackground: () => void /** * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` @@ -100,6 +110,9 @@ export function RoomToolbar({ pipActive, onTogglePiP, handQueueEnabled, + backgroundVisible, + backgroundOpen, + onToggleBackground, handQueue, onRaiseHand, onLowerHand, @@ -130,6 +143,9 @@ export function RoomToolbar({ 2 /* микрофон, камера */ + (handQueueEnabled ? 2 : 0) /* рука, очередь */ + 1 /* настройки */ + + // На телефоне всегда 0 (фича десктопная), но десктопное окно бывает и уже + // 600px — тогда кнопка «Фон» реально есть и должна попасть в подсчёт. + (backgroundVisible ? 1 : 0) + (fullscreenSupported ? 1 : 0) + (chatVisible ? 1 : 0) + 1 /* выйти */ @@ -244,6 +260,23 @@ export function RoomToolbar({ {isCompact ? 'Настройки' : 'Устройства'} + {/* Только десктоп — на мобильном `backgroundVisible` всегда false, + поэтому кнопка не участвует и в `mobileButtonCount` выше. */} + {backgroundVisible && ( + + )} + {fullscreenSupported && (