Files
vidconf/frontend/src/lib/virtualBackground.ts
Max Ronzhin 1089d874af fix(room): замена фона в Safari и Firefox
Проверка поддержки была строже, чем требует библиотека, и молча отрезала два
браузера: кнопки выбора фона не было ни в превью, ни в комнате.

У `@livekit/track-processors` ДВА конвейера обработки кадров: современный
(`MediaStreamTrackProcessor`/`Generator`, только Chrome и производные) и
запасной — рисует кадры в canvas и отдаёт `canvas.captureStream()`. Проверка
требовала именно современный, хотя запасной путь доступен и в Safari, и в
Firefox. Заодно она проверяла `OffscreenCanvas`, но пропускала `VideoFrame`,
`createImageBitmap` и WebGL2, которые библиотеке реально нужны.

Теперь условие буквально повторяет `supportsBackgroundProcessors()` из самой
библиотеки: «умеет считать сегментацию» И «есть хоть какой-то конвейер».
При обновлении пакета сверять с ним.

Пробный WebGL2-контекст (иначе поддержку не определить) создаётся один раз на
жизнь страницы и сразу отпускается через `WEBGL_lose_context`: браузеры
держат ограниченное число живых контекстов.

Проверено: в Firefox 153 детект возвращает «поддерживается» (современного
конвейера нет, запасной есть), в Chrome регрессии нет — процессор
поднимается, трек живой.
2026-08-10 13:16:01 +03:00

213 lines
11 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.
/**
* Замена фона видео: список дефолтных сцен, память о выборе и ленивое создание
* процессора сегментации.
*
* Тяжёлая часть (библиотека + 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
}