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:
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user