Files
vidconf/frontend/src/hooks/useVirtualBackground.ts
Max Ronzhin 359f9fd1f5 fix(room): починить чернеющее своё видео в Safari при выключении фона
Safari гасил собственный обходной путь для чёрного кадра — охрана от
мерцания в livekit-client сравнивает srcObject с тем же объектом
MediaStream, а detach/attach внутри библиотеки промахивался мимо
обработанного трека и мутировал поток на месте. Переприцепляем
элементы вручную после stopProcessor(), чтобы пересоздать MediaStream
и дать защите сработать штатно.
2026-08-11 02:03:24 +03:00

172 lines
9.1 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()
// Safari-обход: `stopProcessor` внутри livekit-client меняет трек
// в элементе «на месте», переиспользуя тот же объект `MediaStream`
// (внутренний `detach` промахивается мимо обработанного трека,
// который реально висел в элементе). Из-за этого собственная
// защита библиотеки от мерцания (`srcObject !== mediaStream` в
// `attachToElement`) не даёт сработать её же обходному пути для
// чёрного кадра в Safari. Пересоздаём `MediaStream` вручную —
// detach обнулит `srcObject`, attach создаст новый объект, защита
// пропустит штатный `play()`. Элемент остаётся в своём документе,
// никуда не переезжает. Копия массива обязательна: `detach`
// правит `attachedElements` по ходу перебора. Уберётся, если
// библиотека когда-нибудь починит эту защиту.
for (const el of [...track.attachedElements]) {
track.detach(el)
track.attach(el)
}
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 }
}