Files
vidconf/frontend/src/hooks/useVirtualBackground.ts
Max Ronzhin 17437880b1 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 и фон молча не включался.
2026-08-10 08:59:47 +03:00

156 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}