Files
vidconf/frontend/src/hooks/useDeviceCheckAccess.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

414 lines
25 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 { 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'
interface UseDeviceCheckAccessResult {
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** Привязать к `ref` `<video>` превью — коллбэк, НЕ объект-реф: карточка
* превью рендерится в разных местах JSX-дерева на разных шагах
* (`input`/`guest-info` в `JoinPage`), и React монтирует для каждого места
* СВОЙ DOM-узел `<video>`, хотя тип компонента один и тот же — обычный
* `ref.current` продолжал бы указывать на старый (уже отмонтированный)
* узел. Коллбэк вызывается при каждом монтировании нового узла и сам
* подключает уже открытый поток (см. докстринг хука) — без этого переход
* между шагами давал бы на месте превью чёрный прямоугольник: поток жив,
* но не подключён к новому элементу. */
videoRef: (node: HTMLVideoElement | null) => void
/** Повесить на карточку (`onPointerDown`/`onKeyDown`) — первое взаимодействие
* внутри неё запускает запрос доступа. Идемпотентно, повторные вызовы —
* no-op (см. `requestedRef`). */
triggerOnGesture: () => void
/** Понятная подсказка про отказ в доступе — `null`, пока запроса не было
* или обе камера/микрофон доступны. Отказ НЕ блокирует форму — вызывающая
* карточка просто показывает текст рядом и позволяет идти дальше. */
hint: string | null
/** Полный сброс: остановить треки камеры и вернуть весь стейт к исходному
* (микрофон отдельного потока не держит, см. `requestAudio`) — звать при
* уходе с карточки, сабмите формы и любой ошибке. Безопасно вызывать
* многократно. */
release: () => void
/**
* «Войти с включённым микрофоном/камерой» — И предпочтение пользователя
* для будущего входа, И (только для видео) реальное состояние превью:
* кнопка камеры действительно останавливает/перезапускает поток (индикатор
* камеры гаснет), а не просто прячет `<video>` поверх работающего потока —
* иначе кнопка «выключить камеру» на этом самом экране обходила бы весь
* смысл фичи. Микрофон отдельного живого потока не держит (см. `requestAudio`),
* поэтому `audioEnabled` — чистый флаг предпочтения, переключается мгновенно.
* По умолчанию `false` — сохраняет поведение существующих инсталляций для
* всех, кто кнопки не трогал. Сбрасывается в `release()` — при уходе с
* карточки следующий заход в неё (напр. «Назад» → снова резолвить) должен
* начинать с чистого состояния, а не с потухшего превью и protection против
* повторного запроса.
*/
videoEnabled: boolean
audioEnabled: boolean
/** Переключить камеру — реально останавливает/перезапускает поток (см. выше). */
toggleVideoEnabled: () => void
toggleAudioEnabled: () => void
/**
* Выбранный фон применить не удалось — превью показывает «сырую» камеру.
* Нужен, чтобы сбой не был МОЛЧАЛИВЫМ: без этого флага пользователь видит
* выбранную плитку с галочкой и обычную картинку и решает, что фон просто
* не работает (ровно на это наступили на приёмке 0.0.35).
*/
backgroundFailed: boolean
}
/**
* Доступ к камере/микрофону на входе (сессия 33, `LoginPage`/`JoinPage`) —
* общая логика для обеих публичных карточек, чтобы не дублировать её между
* гостевым и обычным флоу входа (см. промпт сессии).
*
* Решения и почему:
* - **Камера и микрофон запрашиваются НЕЗАВИСИМО** (`requestVideo`, затем
* `requestAudio`, отдельные вызовы `getUserMedia`) — совместный вызов
* `getUserMedia({audio:true, video:true})` падает целиком при отказе в
* ЛЮБОМ из разрешений, а превью камеры и отдельная подсказка про микрофон
* должны работать даже если пользователь разрешил только одно из двух.
* - **Микрофон никогда не остаётся активным** — превью только у камеры
* («только превью» из задачи), поток микрофона останавливается сразу
* после получения разрешения (см. `requestAudio`), сам факт разрешения
* при этом остаётся выданным браузером — заново спрашивать не будет.
* - **Поток камеры живёт, пока карточка открыта** — останавливается явно
* через `release()` (сабмит/уход/размонтирование/ошибка вызывающей
* стороны) и автоматически при размонтировании самого хука.
* - **Автозапуск без жеста, если разрешение уже выдано** (`navigator.permissions`,
* где поддерживается) — тогда `getUserMedia` не покажет системный диалог
* вообще, и ждать клика незачем; иначе (в т.ч. Safari без Permissions API
* для камеры/микрофона) — только по жесту `triggerOnGesture`, иначе Safari
* и мобильные браузеры молча отклоняют вызов при простой загрузке страницы.
* - **Устройство по умолчанию — сохранённый выбор пользователя**
* (`usePersistentUserChoices`, тот же ключ localStorage, что и в комнате,
* см. `RoomPage.tsx`); если сохранённого ID больше не существует
* (`OverconstrainedError`) — фолбэк на устройство по умолчанию системы.
*/
export function useDeviceCheckAccess(
enabled: boolean,
backgroundUrl: string | null = null,
): UseDeviceCheckAccessResult {
const { userChoices } = usePersistentUserChoices()
// Не в зависимостях эффектов/колбэков ниже — коллбэки живут в event-хендлерах
// (жест), а не в реактивном дереве; актуальное значение достаточно иметь на
// момент фактического вызова, ref обновляется отдельным эффектом.
const userChoicesRef = useRef(userChoices)
useEffect(() => {
userChoicesRef.current = userChoices
}, [userChoices])
const [videoStatus, setVideoStatus] = useState<DeviceCheckStatus>('idle')
const [audioStatus, setAudioStatus] = useState<DeviceCheckStatus>('idle')
const [videoEnabled, setVideoEnabled] = useState(false)
const [audioEnabled, setAudioEnabled] = useState(false)
const toggleAudioEnabled = useCallback(() => setAudioEnabled((v) => !v), [])
const videoStreamRef = useRef<MediaStream | null>(null)
const videoNodeRef = useRef<HTMLVideoElement | null>(null)
// Что реально показывается в `<video>`: либо сам поток камеры, либо поток с
// наложенным фоном. Отдельно от `videoStreamRef` — тот всегда остаётся
// «сырым» источником, который надо остановить при освобождении камеры
// (обработанный трек камеру не держит и сам её не выключит).
const displayStreamRef = useRef<MediaStream | null>(null)
// См. докстринг `videoRef` в интерфейсе выше — коллбэк-реф, переподключает
// уже открытый поток к КАЖДОМУ новому DOM-узлу `<video>` сам, без этого
// переход между шагами с превью терял бы картинку (но не поток — камера
// продолжала бы физически работать, просто без видимого превью).
const videoRef = useCallback((node: HTMLVideoElement | null) => {
videoNodeRef.current = node
if (node) {
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()
}, [])
// Полный сброс — не только остановка треков, но и статусы/флаги/охрана
// повторного запроса. Нужен и на «настоящем» уходе (сабмит/размонтирование),
// и на возврате к этой же карточке В ПРЕДЕЛАХ одного монтирования хука
// (JoinPage не размонтирует компонент между шагами флоу — см. её докстринг):
// без сброса `requestedRef` повторный заход не переспросил бы доступ и
// навсегда остался бы с потухшим превью при formально «granted» статусе.
const release = useCallback(() => {
destroyProcessor()
const stream = videoStreamRef.current
if (stream) {
stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null
}
showStream(null)
requestedRef.current = false
setVideoStatus('idle')
setAudioStatus('idle')
setVideoEnabled(false)
setAudioEnabled(false)
setBackgroundFailed(false)
}, [destroyProcessor, showStream])
// Размонтирование карточки — последний рубеж освобождения камеры: даже
// если вызывающая сторона забудет свой release() на каком-то из путей
// выхода, эта отписка не даст камере остаться гореть.
useEffect(() => () => release(), [release])
const requestVideo = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setVideoStatus('denied')
return
}
setVideoStatus('pending')
try {
const stream = await openStream('video', userChoicesRef.current.videoDeviceId)
videoStreamRef.current = stream
// Сначала показываем «сырой» поток — картинка появляется сразу, а фон
// (если выбран) наложится следом, когда доедет модель.
showStream(stream)
setVideoSourceVersion((version) => version + 1)
setVideoStatus('granted')
// Первичная верификация сразу показывает превью — «включено» по факту
// получения потока, а не отдельным действием пользователя.
setVideoEnabled(true)
} catch {
setVideoStatus('denied')
}
}, [showStream])
// Кнопка камеры реально управляет потоком — выключение останавливает
// треки (индикатор камеры гаснет, ровно то, ради чего вся фича), включение
// обратно — свежий `getUserMedia` (разрешение уже выдано, диалога не будет,
// локально занимает десятки мс). `videoStatus` при выключении остаётся
// `'granted'` намеренно: кнопка не должна блокироваться, доступ никуда не
// делся, остановлен только сам поток.
const toggleVideoEnabled = useCallback(() => {
if (videoEnabled) {
destroyProcessor()
const stream = videoStreamRef.current
if (stream) {
stream.getTracks().forEach((track) => track.stop())
videoStreamRef.current = null
}
showStream(null)
setVideoSourceVersion((version) => version + 1)
setVideoEnabled(false)
return
}
void 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) {
setAudioStatus('denied')
return
}
setAudioStatus('pending')
try {
const stream = await openStream('audio', userChoicesRef.current.audioDeviceId)
// Только подтверждаем, что микрофон реально работает и разрешение
// получено, — держать поток открытым незачем, превью для него нет.
stream.getTracks().forEach((track) => track.stop())
setAudioStatus('granted')
} catch {
setAudioStatus('denied')
}
}, [])
const requestAccess = useCallback(async () => {
if (requestedRef.current) return
requestedRef.current = true
// Сначала видео — оно ценнее (превью), и лучше показать его как можно
// раньше; микрофон следом, отдельным системным диалогом.
await requestVideo()
await requestAudio()
}, [requestVideo, requestAudio])
// Автозапуск без ожидания жеста — только если браузер уже сообщает
// 'granted' по ОБОИМ разрешениям: тогда getUserMedia не покажет диалог,
// и ждать клика незачем. Permissions API для camera/microphone
// поддерживается не везде (Safari) — в catch/при отсутствии API просто
// остаёмся в режиме ожидания жеста, ничего не ломаем.
useEffect(() => {
if (!enabled) return
let cancelled = false
async function precheckAndMaybeAutoStart() {
const permissions = navigator.permissions
if (!permissions?.query) return
try {
const [camera, microphone] = await Promise.all([
permissions.query({ name: 'camera' as PermissionName }),
permissions.query({ name: 'microphone' as PermissionName }),
])
if (!cancelled && camera.state === 'granted' && microphone.state === 'granted') {
void requestAccess()
}
} catch {
// 'camera'/'microphone' — нестандартные имена Permissions API,
// часть браузеров (в т.ч. Safari) их не поддерживает вовсе.
}
}
void precheckAndMaybeAutoStart()
return () => {
cancelled = true
}
}, [enabled, requestAccess])
const triggerOnGesture = useCallback(() => {
if (!enabled) return
void requestAccess()
}, [enabled, requestAccess])
return {
videoStatus,
audioStatus,
videoRef,
triggerOnGesture,
hint: deviceCheckHint(videoStatus, audioStatus),
release,
videoEnabled,
audioEnabled,
toggleVideoEnabled,
toggleAudioEnabled,
backgroundFailed,
}
}
/** Открыть поток `kind` на сохранённом устройстве; если его больше нет —
* фолбэк на устройство по умолчанию системы (см. докстринг хука выше).
*
* `usePersistentUserChoices` хранит НЕвыбранное устройство не как пустую
* строку, а как литерал `"default"` (`@livekit/components-core`,
* `defaultUserChoices`) — это не реальный `deviceId`, и `{deviceId:{exact:
* "default"}}` для видео в Chrome падает `OverconstrainedError` на каждом
* первом визите (фолбэк ниже это лечит, но лишний неудачный проход того не
* стоит) — поэтому сентинел приравнивается к «выбора нет», как и пустая строка. */
async function openStream(kind: 'video' | 'audio', deviceId: string | undefined): Promise<MediaStream> {
const realDeviceId = deviceId && deviceId !== 'default' ? deviceId : undefined
const constraints: MediaStreamConstraints =
kind === 'video'
? { video: realDeviceId ? { deviceId: { exact: realDeviceId } } : true }
: { audio: realDeviceId ? { deviceId: { exact: realDeviceId } } : true }
try {
return await navigator.mediaDevices.getUserMedia(constraints)
} catch (err) {
if (realDeviceId && err instanceof DOMException && err.name === 'OverconstrainedError') {
return navigator.mediaDevices.getUserMedia(kind === 'video' ? { video: true } : { audio: true })
}
throw err
}
}
function deviceCheckHint(video: DeviceCheckStatus, audio: DeviceCheckStatus): string | null {
const videoDenied = video === 'denied'
const audioDenied = audio === 'denied'
if (videoDenied && audioDenied) {
return 'Доступ к камере и микрофону не разрешён — включить их можно будет прямо в конференции'
}
if (videoDenied) {
return 'Камера недоступна — включить её можно будет прямо в конференции'
}
if (audioDenied) {
return 'Микрофон недоступен — включить его можно будет прямо в конференции'
}
return null
}