Проверка поддержки была строже, чем требует библиотека, и молча отрезала два браузера: кнопки выбора фона не было ни в превью, ни в комнате. У `@livekit/track-processors` ДВА конвейера обработки кадров: современный (`MediaStreamTrackProcessor`/`Generator`, только Chrome и производные) и запасной — рисует кадры в canvas и отдаёт `canvas.captureStream()`. Проверка требовала именно современный, хотя запасной путь доступен и в Safari, и в Firefox. Заодно она проверяла `OffscreenCanvas`, но пропускала `VideoFrame`, `createImageBitmap` и WebGL2, которые библиотеке реально нужны. Теперь условие буквально повторяет `supportsBackgroundProcessors()` из самой библиотеки: «умеет считать сегментацию» И «есть хоть какой-то конвейер». При обновлении пакета сверять с ним. Пробный WebGL2-контекст (иначе поддержку не определить) создаётся один раз на жизнь страницы и сразу отпускается через `WEBGL_lose_context`: браузеры держат ограниченное число живых контекстов. Проверено: в Firefox 153 детект возвращает «поддерживается» (современного конвейера нет, запасной есть), в Chrome регрессии нет — процессор поднимается, трек живой.
213 lines
11 KiB
TypeScript
213 lines
11 KiB
TypeScript
/**
|
||
* Замена фона видео: список дефолтных сцен, память о выборе и ленивое создание
|
||
* процессора сегментации.
|
||
*
|
||
* Тяжёлая часть (библиотека + wasm-рантайм MediaPipe + модель, единицы мегабайт)
|
||
* НЕ попадает в основной бандл: `@livekit/track-processors` подключается
|
||
* динамическим `import()` в `createBackgroundProcessor`, то есть только когда
|
||
* пользователь реально включает фон. Вход в конференцию от наличия этой фичи
|
||
* не становится медленнее — это было прямым требованием.
|
||
*
|
||
* 🔴 Ассеты берутся СО СВОЕГО домена (`assetPaths` ниже). По умолчанию
|
||
* библиотека тянет wasm с jsdelivr, а модель — с storage.googleapis.com;
|
||
* VidConf ставят в закрытых контурах без внешнего интернета, и там фича молча
|
||
* не заработала бы. Откуда берутся файлы — см. плагин `mediapipeWasm`
|
||
* в `vite.config.ts` и `public/mediapipe/NOTICE.txt`.
|
||
*/
|
||
|
||
import { Track } from 'livekit-client'
|
||
import type { BackgroundProcessorWrapper } from '@livekit/track-processors'
|
||
|
||
/** Готовая сцена, поставляемая с продуктом (собственные рисунки, см. `design/backgrounds/`). */
|
||
export interface DefaultBackground {
|
||
/** Имя файла без расширения — оно же часть ключа выбора (`default:office`). */
|
||
id: string
|
||
label: string
|
||
url: string
|
||
}
|
||
|
||
export const DEFAULT_BACKGROUNDS: DefaultBackground[] = [
|
||
{ id: 'office', label: 'Офис', url: '/backgrounds/office.webp' },
|
||
{ id: 'beach', label: 'Пляж', url: '/backgrounds/beach.webp' },
|
||
{ id: 'space-station', label: 'Космическая станция', url: '/backgrounds/space-station.webp' },
|
||
]
|
||
|
||
/**
|
||
* Ключ выбранного фона: `none`, `default:<id>` либо `custom:<uuid записи>`.
|
||
*
|
||
* Хранится именно ключ, а не URL картинки: URL своей картинки перестаёт быть
|
||
* валидным, как только пользователь её удалил, и по ключу это видно сразу —
|
||
* записи с таким id в списке нет, значит выбор сбрасывается на «без фона»
|
||
* (см. `resolveBackgroundUrl`).
|
||
*/
|
||
export type BackgroundKey = string
|
||
|
||
export const NO_BACKGROUND: BackgroundKey = 'none'
|
||
|
||
const STORAGE_KEY = 'vidconf.virtualBackground'
|
||
|
||
/**
|
||
* Загрузить сохранённый выбор фона.
|
||
*
|
||
* Выбор персистится между заходами (как режим показа сцены,
|
||
* `lib/stageLayoutMode.ts`) и, что важнее, переживает переход «превью на входе
|
||
* → комната»: пользователь выбирает фон на `JoinPage`, а применяется он к
|
||
* публикуемому треку уже внутри конференции — передавать его через
|
||
* navigation state нельзя, тот теряется при F5.
|
||
*/
|
||
export function loadBackgroundKey(): BackgroundKey {
|
||
try {
|
||
return window.localStorage.getItem(STORAGE_KEY) || NO_BACKGROUND
|
||
} catch {
|
||
// Приватный режим/запрет хранилища — фича должна работать и без памяти.
|
||
return NO_BACKGROUND
|
||
}
|
||
}
|
||
|
||
export function saveBackgroundKey(key: BackgroundKey): void {
|
||
try {
|
||
window.localStorage.setItem(STORAGE_KEY, key)
|
||
} catch {
|
||
// См. `loadBackgroundKey` — молча живём без персиста.
|
||
}
|
||
}
|
||
|
||
/** Своя картинка пользователя в том виде, в каком её отдаёт API. */
|
||
export interface CustomBackground {
|
||
id: string
|
||
url: string
|
||
}
|
||
|
||
/**
|
||
* URL картинки по ключу выбора; `null` — фон не нужен («без фона» либо ключ
|
||
* указывает на уже удалённую свою картинку).
|
||
*/
|
||
export function resolveBackgroundUrl(
|
||
key: BackgroundKey,
|
||
customBackgrounds: CustomBackground[],
|
||
): string | null {
|
||
if (key.startsWith('default:')) {
|
||
const id = key.slice('default:'.length)
|
||
return DEFAULT_BACKGROUNDS.find((item) => item.id === id)?.url ?? null
|
||
}
|
||
if (key.startsWith('custom:')) {
|
||
const id = key.slice('custom:'.length)
|
||
return customBackgrounds.find((item) => item.id === id)?.url ?? null
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Поддерживает ли браузер замену фона.
|
||
*
|
||
* Проверка СИНХРОННАЯ и намеренно не трогает саму библиотеку: решение нужно
|
||
* ДО её загрузки, чтобы не показывать кнопку, которая не сработает, и не
|
||
* тянуть мегабайты впустую. Поэтому здесь буквально повторено условие
|
||
* `supportsBackgroundProcessors()` из `@livekit/track-processors` — при
|
||
* обновлении пакета сверять с ним.
|
||
*
|
||
* ⚠️ Конвейеров у библиотеки ДВА, и требовать современный нельзя:
|
||
* - современный (`MediaStreamTrackProcessor`/`Generator`, Insertable Streams)
|
||
* есть только в Chrome и производных;
|
||
* - запасной рисует кадры в canvas и отдаёт `canvas.captureStream()` — он
|
||
* работает в Safari и Firefox.
|
||
*
|
||
* Первая версия (0.0.35) требовала именно современный конвейер — и фича
|
||
* молча отсутствовала в Safari и Firefox, хотя запасной путь там доступен.
|
||
* Условие ниже — «умеет считать сегментацию» И «есть хоть какой-то конвейер».
|
||
*/
|
||
export function isVirtualBackgroundSupported(): boolean {
|
||
if (typeof window === 'undefined') return false
|
||
return canRunSegmentation() && hasAnyFramePipeline()
|
||
}
|
||
|
||
/**
|
||
* Условие `BackgroundTransformer.isSupported`: чем библиотека считает маску и
|
||
* собирает кадр. WebGL2 проверяется созданием пробного контекста — иначе никак,
|
||
* но результат кэшируется на всю жизнь страницы: браузеры держат ограниченное
|
||
* число живых WebGL-контекстов, и создавать новый на каждый рендер нельзя.
|
||
*/
|
||
let segmentationSupport: boolean | null = null
|
||
function canRunSegmentation(): boolean {
|
||
if (segmentationSupport !== null) return segmentationSupport
|
||
const hasApis =
|
||
typeof OffscreenCanvas !== 'undefined' &&
|
||
typeof VideoFrame !== 'undefined' &&
|
||
typeof createImageBitmap !== 'undefined'
|
||
if (!hasApis) {
|
||
segmentationSupport = false
|
||
return false
|
||
}
|
||
const probe = document.createElement('canvas').getContext('webgl2')
|
||
// Пробный контекст сразу отпускаем — он больше не нужен, а слот в лимите
|
||
// браузера занимал бы до сборки мусора.
|
||
probe?.getExtension('WEBGL_lose_context')?.loseContext()
|
||
segmentationSupport = Boolean(probe)
|
||
return segmentationSupport
|
||
}
|
||
|
||
/** Условие `ProcessorWrapper.isSupported`: современный конвейер ЛИБО запасной на canvas. */
|
||
function hasAnyFramePipeline(): boolean {
|
||
const modern = 'MediaStreamTrackProcessor' in window && 'MediaStreamTrackGenerator' in window
|
||
const fallback =
|
||
typeof HTMLCanvasElement !== 'undefined' && 'captureStream' in HTMLCanvasElement.prototype
|
||
return modern || fallback
|
||
}
|
||
|
||
/**
|
||
* Ассеты MediaPipe со своего домена — см. заголовок файла и `vite.config.ts`.
|
||
*
|
||
* `tasksVisionFileSet` — КАТАЛОГ с wasm-рантаймом: библиотека сама выберет
|
||
* simd- или nosimd-вариант по возможностям браузера, поэтому в каталоге лежат
|
||
* оба. `modelAssetPath` — конкретный файл модели сегментации.
|
||
*/
|
||
const LOCAL_ASSET_PATHS = {
|
||
tasksVisionFileSet: '/mediapipe/wasm',
|
||
modelAssetPath: '/mediapipe/selfie_segmenter.tflite',
|
||
}
|
||
|
||
/**
|
||
* Создать процессор замены фона на картинку `imagePath`.
|
||
*
|
||
* Библиотека грузится динамическим `import()` — первый вызов скачивает её
|
||
* вместе с wasm и моделью, последующие берут из кэша модулей/браузера.
|
||
*/
|
||
export async function createBackgroundProcessor(
|
||
imagePath: string,
|
||
): Promise<BackgroundProcessorWrapper> {
|
||
const { BackgroundProcessor } = await import('@livekit/track-processors')
|
||
return BackgroundProcessor({
|
||
mode: 'virtual-background',
|
||
imagePath,
|
||
assetPaths: LOCAL_ASSET_PATHS,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Запустить процессор на «сыром» треке камеры — для превью входа, где
|
||
* LiveKit-трека ещё нет (в комнате всё это делает сам
|
||
* `LocalVideoTrack.setProcessor`).
|
||
*
|
||
* ⚠️ Процессору обязателен `element` — `<video>`, в который проигрывается
|
||
* ИСХОДНЫЙ поток: библиотека читает из него кадры и падает
|
||
* `TypeError: Currently only video transformers are supported`, если элемента
|
||
* нет. Порядок ровно как у самого LiveKit (`LocalTrack.setProcessor`):
|
||
* сначала `init`, только потом подключение потока и `play()`.
|
||
*
|
||
* Элемент в DOM не добавляется — он служебный, зритель видит уже обработанный
|
||
* поток; возвращается вызывающему, чтобы тот освободил его вместе с процессором.
|
||
*/
|
||
export async function startProcessorOnTrack(
|
||
processor: BackgroundProcessorWrapper,
|
||
rawTrack: MediaStreamTrack,
|
||
): Promise<HTMLVideoElement> {
|
||
const element = document.createElement('video')
|
||
await processor.init({ kind: Track.Kind.Video, track: rawTrack, element })
|
||
element.muted = true
|
||
element.playsInline = true
|
||
element.autoplay = true
|
||
element.srcObject = new MediaStream([rawTrack])
|
||
await element.play()
|
||
return element
|
||
}
|