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