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:
173
frontend/src/lib/virtualBackground.ts
Normal file
173
frontend/src/lib/virtualBackground.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Замена фона видео: список дефолтных сцен, память о выборе и ленивое создание
|
||||
* процессора сегментации.
|
||||
*
|
||||
* Тяжёлая часть (библиотека + 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Поддерживает ли браузер сегментацию вообще.
|
||||
*
|
||||
* Проверяется по наличию API конвейера обработки кадров (Insertable Streams /
|
||||
* `MediaStreamTrackProcessor`) — их нет, например, в Firefox. Проверка
|
||||
* СИНХРОННАЯ и намеренно не трогает саму библиотеку: она нужна до её загрузки,
|
||||
* чтобы не показывать кнопку, которая всё равно не сработает, и не тянуть
|
||||
* мегабайты впустую.
|
||||
*/
|
||||
export function isVirtualBackgroundSupported(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return (
|
||||
'MediaStreamTrackProcessor' in window &&
|
||||
'MediaStreamTrackGenerator' in window &&
|
||||
typeof OffscreenCanvas !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ассеты 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
|
||||
}
|
||||
Reference in New Issue
Block a user