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:
2026-08-10 08:59:47 +03:00
parent fec9255baa
commit 17437880b1
37 changed files with 1918 additions and 18 deletions

View File

@@ -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,
}
}