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:
37
frontend/package-lock.json
generated
37
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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) — запрос доступа к камере/микрофону
|
||||
* и превью камеры на странице логина и в карточке «Как вас зовут?». */
|
||||
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`). */
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface ConferenceJoinData {
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
|
||||
stage_max_tiles: number
|
||||
/** Включён ли модуль «замена фона» — при `false` кнопка «Фон» в тулбаре не рендерится. */
|
||||
virtual_background_enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<PublicSettingsOut> {
|
||||
|
||||
@@ -62,6 +62,42 @@ export async function deleteMyAvatar(): Promise<void> {
|
||||
await apiRequest('/users/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Своя картинка пользователя для замены фона видео. */
|
||||
export interface MyBackground {
|
||||
id: string
|
||||
/** Путь на своём домене (`/media/backgrounds/...`), раздаёт nginx. */
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Ответ списка своих картинок фона — вместе с серверным лимитом на их число. */
|
||||
export interface MyBackgroundsResponse {
|
||||
items: MyBackground[]
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** Свои картинки фона текущего пользователя. Гостю недоступно (401) — у него нет профиля. */
|
||||
export async function listMyBackgrounds(): Promise<MyBackgroundsResponse> {
|
||||
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds')
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить свою картинку фона; возвращается весь список заново.
|
||||
*
|
||||
* Картинка ужимается ДО отправки (`lib/imageResize.ts`) — сервер её не
|
||||
* пережимает (Pillow на backend нет). Коды ошибок: 409 — упёрлись в лимит,
|
||||
* 413 — файл больше 2 МБ, 415 — недопустимый тип.
|
||||
*/
|
||||
export async function uploadMyBackground(file: File | Blob): Promise<MyBackgroundsResponse> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, 'background.webp')
|
||||
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
/** Удалить свою картинку фона — 204 без тела; файл на диске удаляется вместе с записью. */
|
||||
export async function deleteMyBackground(id: string): Promise<void> {
|
||||
await apiRequest(`/users/me/backgrounds/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело смены пароля текущего пользователя. */
|
||||
export interface PasswordChangePayload {
|
||||
current_password: string
|
||||
|
||||
@@ -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<AiLevel>(data.ai_level)
|
||||
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 (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 }) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Замена фона видео</strong>
|
||||
<span>
|
||||
Выбор фона в конференции: три готовые сцены и свои картинки (до 10 штук,
|
||||
загружаются в профиле). Работает только на компьютере — фон считает нейросеть
|
||||
на каждом кадре, и на телефоне это греет устройство и просаживает встречу
|
||||
</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={virtualBackgroundEnabled}
|
||||
onChange={(e) => setVirtualBackgroundEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-copy">
|
||||
<strong>Транскрибация и суммаризация (AI)</strong>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backgroundPicker && <div className="device-check-backgrounds">{backgroundPicker}</div>}
|
||||
|
||||
{hint && <p className="field-hint device-check-hint">{hint}</p>}
|
||||
</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 {
|
||||
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({
|
||||
<span className="label">{isCompact ? 'Настройки' : 'Устройства'}</span>
|
||||
</button>
|
||||
|
||||
{/* Только десктоп — на мобильном `backgroundVisible` всегда false,
|
||||
поэтому кнопка не участвует и в `mobileButtonCount` выше. */}
|
||||
{backgroundVisible && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn tb-btn--background${backgroundOpen ? ' is-panel-open' : ''}`}
|
||||
aria-label={backgroundOpen ? 'Закрыть выбор фона' : 'Выбрать фон'}
|
||||
aria-pressed={backgroundOpen}
|
||||
onClick={onToggleBackground}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<Image className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Фон</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{fullscreenSupported && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
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 { 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'
|
||||
|
||||
@@ -48,6 +50,13 @@ interface UseDeviceCheckAccessResult {
|
||||
/** Переключить камеру — реально останавливает/перезапускает поток (см. выше). */
|
||||
toggleVideoEnabled: () => void
|
||||
toggleAudioEnabled: () => void
|
||||
/**
|
||||
* Выбранный фон применить не удалось — превью показывает «сырую» камеру.
|
||||
* Нужен, чтобы сбой не был МОЛЧАЛИВЫМ: без этого флага пользователь видит
|
||||
* выбранную плитку с галочкой и обычную картинку и решает, что фон просто
|
||||
* не работает (ровно на это наступили на приёмке 0.0.35).
|
||||
*/
|
||||
backgroundFailed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +87,10 @@ interface UseDeviceCheckAccessResult {
|
||||
* см. `RoomPage.tsx`); если сохранённого ID больше не существует
|
||||
* (`OverconstrainedError`) — фолбэк на устройство по умолчанию системы.
|
||||
*/
|
||||
export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResult {
|
||||
export function useDeviceCheckAccess(
|
||||
enabled: boolean,
|
||||
backgroundUrl: string | null = null,
|
||||
): UseDeviceCheckAccessResult {
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
// Не в зависимостях эффектов/колбэков ниже — коллбэки живут в event-хендлерах
|
||||
// (жест), а не в реактивном дереве; актуальное значение достаточно иметь на
|
||||
@@ -96,6 +108,11 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
|
||||
const videoStreamRef = useRef<MediaStream | null>(null)
|
||||
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
|
||||
// Что реально показывается в `<video>`: либо сам поток камеры, либо поток с
|
||||
// наложенным фоном. Отдельно от `videoStreamRef` — тот всегда остаётся
|
||||
// «сырым» источником, который надо остановить при освобождении камеры
|
||||
// (обработанный трек камеру не держит и сам её не выключит).
|
||||
const displayStreamRef = useRef<MediaStream | null>(null)
|
||||
// См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает
|
||||
// уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого
|
||||
// переход между шагами с превью терял бы картинку (но не поток — камера
|
||||
@@ -103,11 +120,53 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
const videoRef = useCallback((node: HTMLVideoElement | null) => {
|
||||
videoNodeRef.current = node
|
||||
if (node) {
|
||||
node.srcObject = videoStreamRef.current
|
||||
node.srcObject = displayStreamRef.current ?? videoStreamRef.current
|
||||
}
|
||||
}, [])
|
||||
const requestedRef = useRef(false)
|
||||
|
||||
// --- Замена фона в превью (сессия 35) ---------------------------------
|
||||
// Процессор сегментации, живущий поверх «сырого» трека камеры, и трек, на
|
||||
// который он навешен (по нему видно, что источник сменился и процессор надо
|
||||
// пересоздать: выключение/включение камеры выдаёт НОВЫЙ трек).
|
||||
const processorRef = useRef<BackgroundProcessorWrapper | null>(null)
|
||||
const processedSourceRef = useRef<MediaStreamTrack | null>(null)
|
||||
// Служебный `<video>` с ИСХОДНЫМ потоком, из которого процессор читает
|
||||
// кадры (см. `startProcessorOnTrack`) — в DOM не попадает, но отпускать его
|
||||
// надо явно, иначе он продолжит крутить поток после уничтожения процессора.
|
||||
const processorElementRef = useRef<HTMLVideoElement | null>(null)
|
||||
// Все операции с процессором строго последовательны: они асинхронны и
|
||||
// небыстры (первый раз — ещё и скачивание модели), а щёлкать по фонам можно
|
||||
// сколько угодно быстро.
|
||||
const chainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
// Счётчик смен «сырого» потока — по нему эффект синхронизации понимает, что
|
||||
// источник изменился. Отдельное число, а не сам поток в зависимостях:
|
||||
// MediaStream не участвует в реактивном стейте, реф React не отслеживает.
|
||||
const [videoSourceVersion, setVideoSourceVersion] = useState(0)
|
||||
const [backgroundFailed, setBackgroundFailed] = useState(false)
|
||||
|
||||
const showStream = useCallback((stream: MediaStream | null) => {
|
||||
displayStreamRef.current = stream
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = stream
|
||||
}
|
||||
}, [])
|
||||
|
||||
const destroyProcessor = useCallback(() => {
|
||||
const processor = processorRef.current
|
||||
processorRef.current = null
|
||||
processedSourceRef.current = null
|
||||
const element = processorElementRef.current
|
||||
processorElementRef.current = null
|
||||
if (element) {
|
||||
element.pause()
|
||||
element.srcObject = null
|
||||
}
|
||||
// Освобождение асинхронное, но ждать его некому и незачем: вызывающая
|
||||
// сторона уже перешла к показу «сырого» потока либо гасит камеру.
|
||||
if (processor) void processor.destroy()
|
||||
}, [])
|
||||
|
||||
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана
|
||||
// повторного запроса. Нужен и на «настоящем» уходе (сабмит/размонтирование),
|
||||
// и на возврате к этой же карточке В ПРЕДЕЛАХ одного монтирования хука
|
||||
@@ -115,20 +174,20 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
// без сброса `requestedRef` повторный заход не переспросил бы доступ и
|
||||
// навсегда остался бы с потухшим превью при formально «granted» статусе.
|
||||
const release = useCallback(() => {
|
||||
destroyProcessor()
|
||||
const stream = videoStreamRef.current
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
videoStreamRef.current = null
|
||||
}
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = null
|
||||
}
|
||||
showStream(null)
|
||||
requestedRef.current = false
|
||||
setVideoStatus('idle')
|
||||
setAudioStatus('idle')
|
||||
setVideoEnabled(false)
|
||||
setAudioEnabled(false)
|
||||
}, [])
|
||||
setBackgroundFailed(false)
|
||||
}, [destroyProcessor, showStream])
|
||||
|
||||
// Размонтирование карточки — последний рубеж освобождения камеры: даже
|
||||
// если вызывающая сторона забудет свой release() на каком-то из путей
|
||||
@@ -144,9 +203,10 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
try {
|
||||
const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
|
||||
videoStreamRef.current = stream
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = stream
|
||||
}
|
||||
// Сначала показываем «сырой» поток — картинка появляется сразу, а фон
|
||||
// (если выбран) наложится следом, когда доедет модель.
|
||||
showStream(stream)
|
||||
setVideoSourceVersion((version) => version + 1)
|
||||
setVideoStatus('granted')
|
||||
// Первичная верификация сразу показывает превью — «включено» по факту
|
||||
// получения потока, а не отдельным действием пользователя.
|
||||
@@ -154,7 +214,7 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
} catch {
|
||||
setVideoStatus('denied')
|
||||
}
|
||||
}, [])
|
||||
}, [showStream])
|
||||
|
||||
// Кнопка камеры реально управляет потоком — выключение останавливает
|
||||
// треки (индикатор камеры гаснет, ровно то, ради чего вся фича), включение
|
||||
@@ -164,19 +224,77 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
// делся, остановлен только сам поток.
|
||||
const toggleVideoEnabled = useCallback(() => {
|
||||
if (videoEnabled) {
|
||||
destroyProcessor()
|
||||
const stream = videoStreamRef.current
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
videoStreamRef.current = null
|
||||
}
|
||||
if (videoNodeRef.current) {
|
||||
videoNodeRef.current.srcObject = null
|
||||
}
|
||||
showStream(null)
|
||||
setVideoSourceVersion((version) => version + 1)
|
||||
setVideoEnabled(false)
|
||||
return
|
||||
}
|
||||
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 () => {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
@@ -250,6 +368,7 @@ export function useDeviceCheckAccess(enabled: boolean): UseDeviceCheckAccessResu
|
||||
audioEnabled,
|
||||
toggleVideoEnabled,
|
||||
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 { AlertCircle, ArrowRight, CheckCircle2, Eye, EyeOff, Hash, Link2, Lock, Mail, User, Video } from 'lucide-react'
|
||||
import {
|
||||
@@ -14,6 +14,16 @@ import { DeviceCheckCard } from '@/components/auth/DeviceCheckCard'
|
||||
import { LogoMark } from '@/components/ui/LogoMark'
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||
import { useDeviceCheckAccess } from '@/hooks/useDeviceCheckAccess'
|
||||
import { useMyBackgrounds } from '@/hooks/useMyBackgrounds'
|
||||
import { BackgroundPicker } from '@/components/background/BackgroundPicker'
|
||||
import { isDesktopDevice } from '@/lib/isDesktopDevice'
|
||||
import {
|
||||
isVirtualBackgroundSupported,
|
||||
loadBackgroundKey,
|
||||
resolveBackgroundUrl,
|
||||
saveBackgroundKey,
|
||||
type BackgroundKey,
|
||||
} from '@/lib/virtualBackground'
|
||||
import { usePublicSettings } from '@/hooks/usePublicSettings'
|
||||
import { parseJoinQuery } from '@/lib/parseJoinQuery'
|
||||
import '@/styles/join.css'
|
||||
@@ -92,7 +102,32 @@ export function JoinPage() {
|
||||
// перезахватывается (что вдобавок раскрутило бы известную грабли с
|
||||
// deviceId `"default"`, см. `useDeviceCheckAccess.ts`).
|
||||
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` целиком
|
||||
// пересоздаётся на каждый рендер (не мемоизирован), а `release` сам по
|
||||
// себе стабилен (react-hooks/exhaustive-deps иначе требует весь объект
|
||||
@@ -198,6 +233,7 @@ export function JoinPage() {
|
||||
handQueueEnabled: data.hand_queue_enabled,
|
||||
publishQualityCap: data.publish_quality_cap,
|
||||
stageMaxTiles: data.stage_max_tiles,
|
||||
virtualBackgroundEnabled: data.virtual_background_enabled,
|
||||
// Кнопки превью «войти с камерой/микрофоном» — теперь доступны и
|
||||
// гостю (input → guest-info → сюда), и авторизованному пользователю
|
||||
// (input → connecting → сюда, guest-info он не проходит вовсе, см.
|
||||
@@ -287,6 +323,20 @@ export function JoinPage() {
|
||||
audioEnabled={deviceCheck.audioEnabled}
|
||||
onToggleVideo={deviceCheck.toggleVideoEnabled}
|
||||
onToggleAudio={deviceCheck.toggleAudioEnabled}
|
||||
backgroundPicker={
|
||||
backgroundPickerVisible ? (
|
||||
<>
|
||||
<BackgroundPicker
|
||||
value={backgroundKey}
|
||||
onChange={handleBackgroundChange}
|
||||
customBackgrounds={myBackgrounds}
|
||||
/>
|
||||
{deviceCheck.backgroundFailed && (
|
||||
<p className="bg-hint">Не удалось применить фон в превью</p>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
@@ -357,6 +407,20 @@ export function JoinPage() {
|
||||
audioEnabled={deviceCheck.audioEnabled}
|
||||
onToggleVideo={deviceCheck.toggleVideoEnabled}
|
||||
onToggleAudio={deviceCheck.toggleAudioEnabled}
|
||||
backgroundPicker={
|
||||
backgroundPickerVisible ? (
|
||||
<>
|
||||
<BackgroundPicker
|
||||
value={backgroundKey}
|
||||
onChange={handleBackgroundChange}
|
||||
customBackgrounds={myBackgrounds}
|
||||
/>
|
||||
{deviceCheck.backgroundFailed && (
|
||||
<p className="bg-hint">Не удалось применить фон в превью</p>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flow-icon" aria-hidden="true">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Select } from '@/components/ui/Select'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users'
|
||||
import { MyBackgroundsSection } from '@/components/background/MyBackgroundsSection'
|
||||
import { usePublicSettings } from '@/hooks/usePublicSettings'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import '@/styles/lobby.css'
|
||||
import '@/styles/profile.css'
|
||||
@@ -38,6 +40,11 @@ export function ProfilePage() {
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
|
||||
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
|
||||
// Раздел «Свои фоны» есть, только пока модуль включён в админке — иначе
|
||||
// картинкам, которые никак не применить, в профиле не место (то же правило,
|
||||
// что и с кнопкой «Фон» в комнате: выключенный модуль не оставляет мёртвых
|
||||
// элементов интерфейса).
|
||||
const { data: publicSettings } = usePublicSettings()
|
||||
const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))]
|
||||
|
||||
async function afterProfileChange() {
|
||||
@@ -216,6 +223,8 @@ export function ProfilePage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{publicSettings?.virtual_background_enabled && <MyBackgroundsSection />}
|
||||
|
||||
<section className="profile-card">
|
||||
<h2>Смена пароля</h2>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
|
||||
@@ -12,12 +12,15 @@ import { useAuth } from '@/auth/useAuth'
|
||||
import { useChat } from '@/hooks/useChat'
|
||||
import { useFullscreen } from '@/hooks/useFullscreen'
|
||||
import { useRoomPiP } from '@/hooks/useRoomPiP'
|
||||
import { isDesktopDevice } from '@/lib/isDesktopDevice'
|
||||
import { isVirtualBackgroundSupported } from '@/lib/virtualBackground'
|
||||
import { RoomTopbar } from '@/components/room/RoomTopbar'
|
||||
import { RoomStage } from '@/components/room/RoomStage'
|
||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
import { VirtualBackgroundController } from '@/components/room/VirtualBackgroundController'
|
||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
import { buildPublishDefaults } from '@/lib/publishQualityCap'
|
||||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
@@ -69,6 +72,8 @@ interface RoomJoinState {
|
||||
*/
|
||||
publishQualityCap?: PublishQualityCap
|
||||
stageMaxTiles?: number
|
||||
/** `JoinOut.virtual_background_enabled` — при `false` кнопки «Фон» нет вовсе. */
|
||||
virtualBackgroundEnabled?: boolean
|
||||
/**
|
||||
* Выбор пользователя на превью JoinPage (сессия 33, «проверка устройств на
|
||||
* входе») — войти с уже включённым микрофоном/камерой. Отсутствует (→
|
||||
@@ -222,6 +227,7 @@ export function RoomPage() {
|
||||
handQueueEnabled: result.hand_queue_enabled,
|
||||
publishQualityCap: result.publish_quality_cap,
|
||||
stageMaxTiles: result.stage_max_tiles,
|
||||
virtualBackgroundEnabled: result.virtual_background_enabled,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -290,6 +296,20 @@ export function RoomPage() {
|
||||
const fullscreen = useFullscreen(roomRootRef)
|
||||
const pip = useRoomPiP(roomRootRef)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [backgroundOpen, setBackgroundOpen] = useState(false)
|
||||
|
||||
// Может ли этот участник вообще пользоваться заменой фона. Считается ОДИН
|
||||
// раз при монтировании: и тип устройства, и поддержка конвейера обработки
|
||||
// кадров браузером в пределах вкладки не меняются.
|
||||
//
|
||||
// ⚠️ Про десктоп — именно `isDesktopDevice` (возможности устройства), а НЕ
|
||||
// `useIsCompactViewport` (ширина окна): узкое окно на десктопе — всё ещё
|
||||
// десктоп, а широкий планшет — всё ещё планшет, который сегментация греет.
|
||||
// Подробности — докстринг `lib/isDesktopDevice.ts`.
|
||||
const [backgroundCapable] = useState(
|
||||
() => isDesktopDevice() && isVirtualBackgroundSupported(),
|
||||
)
|
||||
const backgroundVisible = backgroundCapable && Boolean(joinState?.virtualBackgroundEnabled)
|
||||
|
||||
// Топбар и тулбар в полноэкранном режиме — оверлей поверх сцены (см.
|
||||
// `.room-fullscreen-overlay` в room.css), а не часть потока: показываются
|
||||
@@ -547,6 +567,9 @@ export function RoomPage() {
|
||||
pipActive={pip.active}
|
||||
onTogglePiP={pip.toggle}
|
||||
handQueueEnabled={Boolean(joinState.handQueueEnabled)}
|
||||
backgroundVisible={backgroundVisible}
|
||||
backgroundOpen={backgroundOpen}
|
||||
onToggleBackground={() => setBackgroundOpen((open) => !open)}
|
||||
handQueue={chat.handQueue}
|
||||
onRaiseHand={chat.raiseHand}
|
||||
onLowerHand={() => chat.lowerHand()}
|
||||
@@ -559,6 +582,15 @@ export function RoomPage() {
|
||||
onLeave={handleLeaveClick}
|
||||
/>
|
||||
</div>
|
||||
{/* Смонтирован всё время, пока модуль включён, — фон живёт независимо
|
||||
от того, открыто ли окно выбора (см. докстринг компонента). */}
|
||||
{backgroundVisible && (
|
||||
<VirtualBackgroundController
|
||||
open={backgroundOpen}
|
||||
onClose={() => setBackgroundOpen(false)}
|
||||
canManageOwn={status === 'authenticated'}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<DeviceSettingsDialog
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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 { 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user