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:` / `custom:`). */ 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(loadBackgroundKey) const [status, setStatus] = useState('idle') const selectBackground = useCallback((key: BackgroundKey) => { setBackgroundKey(key) saveBackgroundKey(key) }, []) const processorRef = useRef(null) // К какому треку и с какой картинкой процессор реально привязан сейчас — // именно ФАКТИЧЕСКОЕ состояние, а не желаемое: по нему эффект понимает, // что делать, и не переустанавливает уже установленное. const appliedRef = useRef<{ track: LocalVideoTrack | null; url: string | null }>({ track: null, url: null, }) const chainRef = useRef>(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 } }