(front) handle virtual background in custom processor

BackgroundBlurCustomProcessor is renamed to BackgroundCustomProcessor in
order to reflect the fact that is now handles virtual backgrounds too.
BackgroundBlurFactory is also renamed to BackgroundProcessorFactory.
The processor serialization handling has also been updated in order
to support various options, also if persisted in local storage.
This commit is contained in:
Nathan Vasse
2025-01-31 15:42:57 +01:00
committed by NathanVss
parent 465bf293f0
commit 08c5245b48
7 changed files with 142 additions and 39 deletions

View File

@@ -16,7 +16,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { LocalUserChoices } from '../routes/Room' import { LocalUserChoices } from '../routes/Room'
import { BackgroundBlurFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -114,7 +114,7 @@ export const Conference = ({
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={ video={
userConfig.videoEnabled && { userConfig.videoEnabled && {
processor: BackgroundBlurFactory.deserializeProcessor( processor: BackgroundProcessorFactory.deserializeProcessor(
userConfig.processorSerialized userConfig.processorSerialized
), ),
} }

View File

@@ -7,9 +7,8 @@ import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice' import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field' import { Field } from '@/primitives/Field'
import { Form } from '@/primitives' import { Button, Dialog, Text, Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx' import { HStack, VStack } from '@/styled-system/jsx'
import { Button, Dialog } from '@/primitives'
import { LocalUserChoices } from '../routes/Room' import { LocalUserChoices } from '../routes/Room'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react' import { RiImageCircleAiFill } from '@remixicon/react'
@@ -18,7 +17,7 @@ import {
EffectsConfigurationProps, EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration' } from '../livekit/components/effects/EffectsConfiguration'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core' import { isMobileBrowser } from '@livekit/components-core'
const onError = (e: Error) => console.error('ERROR', e) const onError = (e: Error) => console.error('ERROR', e)
@@ -31,7 +30,7 @@ const Effects = ({
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true) const openDialog = () => setIsDialogOpen(true)
if (!BackgroundBlurFactory.isSupported() || isMobileBrowser()) { if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) {
return return
} }
@@ -49,11 +48,19 @@ const Effects = ({
level={1} level={1}
className={css({ className={css({
textStyle: 'h1', textStyle: 'h1',
marginBottom: '1.5rem', marginBottom: '0.25rem',
})} })}
> >
{t('title')} {t('title')}
</Heading> </Heading>
<Text
variant="subTitle"
className={css({
marginBottom: '1.5rem',
})}
>
{t('subTitle')}
</Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} /> <EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
</Dialog> </Dialog>
<div <div
@@ -105,6 +112,8 @@ export const Join = ({
saveProcessorSerialized, saveProcessorSerialized,
} = usePersistentUserChoices({}) } = usePersistentUserChoices({})
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [audioDeviceId, setAudioDeviceId] = useState<string>( const [audioDeviceId, setAudioDeviceId] = useState<string>(
initialUserChoices.audioDeviceId initialUserChoices.audioDeviceId
) )
@@ -113,7 +122,7 @@ export const Join = ({
) )
const [username, setUsername] = useState<string>(initialUserChoices.username) const [username, setUsername] = useState<string>(initialUserChoices.username)
const [processor, setProcessor] = useState( const [processor, setProcessor] = useState(
BackgroundBlurFactory.deserializeProcessor( BackgroundProcessorFactory.deserializeProcessor(
initialUserChoices.processorSerialized initialUserChoices.processorSerialized
) )
) )
@@ -129,12 +138,15 @@ export const Join = ({
useEffect(() => { useEffect(() => {
saveUsername(username) saveUsername(username)
}, [username, saveUsername]) }, [username, saveUsername])
useEffect(() => { useEffect(() => {
saveProcessorSerialized(processor?.serialize()) saveProcessorSerialized(processor?.serialize())
}, [processor, saveProcessorSerialized]) }, [
processor,
const [audioEnabled, setAudioEnabled] = useState(true) saveProcessorSerialized,
const [videoEnabled, setVideoEnabled] = useState(true) // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(processor?.serialize()),
])
const tracks = usePreviewTracks( const tracks = usePreviewTracks(
{ {

View File

@@ -5,7 +5,7 @@ import {
} from '@livekit/track-processors' } from '@livekit/track-processors'
import { ProcessorOptions, Track } from 'livekit-client' import { ProcessorOptions, Track } from 'livekit-client'
import { import {
BackgroundBlurProcessorInterface, BackgroundProcessorInterface,
BackgroundOptions, BackgroundOptions,
ProcessorType, ProcessorType,
} from '.' } from '.'
@@ -16,9 +16,9 @@ import {
* used accross the project. * used accross the project.
*/ */
export class BackgroundBlurTrackProcessorJsWrapper export class BackgroundBlurTrackProcessorJsWrapper
implements BackgroundBlurProcessorInterface implements BackgroundProcessorInterface
{ {
name: string = 'Blur' name: string = 'blur'
processor: ProcessorWrapper<BackgroundOptions> processor: ProcessorWrapper<BackgroundOptions>

View File

@@ -12,7 +12,7 @@ import {
timerWorkerScript, timerWorkerScript,
} from './TimerWorker' } from './TimerWorker'
import { import {
BackgroundBlurProcessorInterface, BackgroundProcessorInterface,
BackgroundOptions, BackgroundOptions,
ProcessorType, ProcessorType,
} from '.' } from '.'
@@ -32,9 +32,7 @@ const DEFAULT_BLUR = '10'
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and * It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
* MediaStreamTrackProcessor. * MediaStreamTrackProcessor.
*/ */
export class BackgroundBlurCustomProcessor export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
implements BackgroundBlurProcessorInterface
{
options: BackgroundOptions options: BackgroundOptions
name: string name: string
processedTrack?: MediaStreamTrack | undefined processedTrack?: MediaStreamTrack | undefined
@@ -63,9 +61,18 @@ export class BackgroundBlurCustomProcessor
timerWorker?: Worker timerWorker?: Worker
type: ProcessorType
virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) { constructor(opts: BackgroundOptions) {
this.name = 'blur' this.name = 'blur'
this.options = opts this.options = opts
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
} }
static get isSupported() { static get isSupported() {
@@ -81,6 +88,7 @@ export class BackgroundBlurCustomProcessor
this.sourceSettings = this.source!.getSettings() this.sourceSettings = this.source!.getSettings()
this.videoElement = opts.element as HTMLVideoElement this.videoElement = opts.element as HTMLVideoElement
this._initVirtualBackgroundImage()
this._createMainCanvas() this._createMainCanvas()
this._createMaskCanvas() this._createMaskCanvas()
@@ -98,8 +106,21 @@ export class BackgroundBlurCustomProcessor
posthog.capture('firefox-blurring-init') posthog.capture('firefox-blurring-init')
} }
_initVirtualBackgroundImage() {
const needsUpdate =
this.options.imagePath &&
this.virtualBackgroundImage &&
this.virtualBackgroundImage.src !== this.options.imagePath
if (this.options.imagePath || needsUpdate) {
this.virtualBackgroundImage = document.createElement('img')
this.virtualBackgroundImage.crossOrigin = 'anonymous'
this.virtualBackgroundImage.src = this.options.imagePath!
}
}
update(opts: BackgroundOptions): void { update(opts: BackgroundOptions): void {
this.options = opts this.options = opts
this._initVirtualBackgroundImage()
} }
_initWorker() { _initWorker() {
@@ -201,6 +222,7 @@ export class BackgroundBlurCustomProcessor
this.outputCanvasCtx!.globalCompositeOperation = 'copy' this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)' this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage( this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!, this.segmentationMaskCanvas!,
0, 0,
@@ -213,19 +235,69 @@ export class BackgroundBlurCustomProcessor
this.videoElement!.videoHeight this.videoElement!.videoHeight
) )
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in' this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none' this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0) this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw blurry background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over' this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)` this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0) this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
} }
/**
* TODO: future improvement with WebGL.
*/
async drawVirtualBackground() {
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
}
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
this.outputCanvasCtx!.filter = 'blur(8px)'
// Put opacity mask.
this.outputCanvasCtx!.drawImage(
this.segmentationMaskCanvas!,
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT,
0,
0,
this.videoElement!.videoWidth,
this.videoElement!.videoHeight
)
// Draw clear body.
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
this.outputCanvasCtx!.filter = 'none'
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
// Draw virtual background.
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
this.outputCanvasCtx!.drawImage(
this.virtualBackgroundImage!,
0,
0,
this.outputCanvas!.width,
this.outputCanvas!.height
)
}
async process() { async process() {
await this.sizeSource() await this.sizeSource()
await this.segment() await this.segment()
await this.blur()
if (this.options.blurRadius) {
await this.blur()
} else {
await this.drawVirtualBackground()
}
this.timerWorker!.postMessage({ this.timerWorker!.postMessage({
id: SET_TIMEOUT, id: SET_TIMEOUT,
timeMs: 1000 / 30, timeMs: 1000 / 30,
@@ -284,12 +356,12 @@ export class BackgroundBlurCustomProcessor
} }
clone() { clone() {
return new BackgroundBlurCustomProcessor(this.options) return new BackgroundCustomProcessor(this.options)
} }
serialize() { serialize() {
return { return {
type: ProcessorType.BLUR, type: this.type,
options: this.options, options: this.options,
} }
} }

View File

@@ -1,10 +1,12 @@
import { ProcessorWrapper } from '@livekit/track-processors' import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client' import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper' import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor' import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
export type BackgroundOptions = { export type BackgroundOptions = {
blurRadius?: number blurRadius?: number
imagePath?: string
} }
export interface ProcessorSerialized { export interface ProcessorSerialized {
@@ -12,40 +14,49 @@ export interface ProcessorSerialized {
options: BackgroundOptions options: BackgroundOptions
} }
export interface BackgroundBlurProcessorInterface export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> { extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): void update(opts: BackgroundOptions): void
options: BackgroundOptions options: BackgroundOptions
clone(): BackgroundBlurProcessorInterface clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized serialize(): ProcessorSerialized
} }
export enum ProcessorType { export enum ProcessorType {
BLUR = 'blur', BLUR = 'blur',
VIRTUAL = 'virtual',
} }
export class BackgroundBlurFactory { export class BackgroundProcessorFactory {
static isSupported() { static isSupported() {
return ( return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
)
} }
static getProcessor( static getProcessor(
type: ProcessorType,
opts: BackgroundOptions opts: BackgroundOptions
): BackgroundBlurProcessorInterface | undefined { ): BackgroundProcessorInterface | undefined {
if (ProcessorWrapper.isSupported) { if (type === ProcessorType.BLUR) {
return new BackgroundBlurTrackProcessorJsWrapper(opts) if (ProcessorWrapper.isSupported) {
} return new BackgroundBlurTrackProcessorJsWrapper(opts)
if (BackgroundBlurCustomProcessor.isSupported) { }
return new BackgroundBlurCustomProcessor(opts) if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} else if (type === ProcessorType.VIRTUAL) {
if (ProcessorWrapper.isSupported) {
return new BackgroundVirtualTrackProcessorJsWrapper(opts)
}
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} }
return undefined return undefined
} }
static deserializeProcessor(data?: ProcessorSerialized) { static deserializeProcessor(data?: ProcessorSerialized) {
if (data?.type === ProcessorType.BLUR) { if (data?.type) {
return BackgroundBlurFactory.getProcessor(data?.options) return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
} }
return undefined return undefined
} }

View File

@@ -27,7 +27,7 @@ import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect } from 'react' import { useEffect } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundBlurFactory } from '../blur' import { BackgroundProcessorFactory } from '../blur'
export type ToggleSource = Exclude< export type ToggleSource = Exclude<
Track.Source, Track.Source,
@@ -115,7 +115,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
* *
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121 * See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/ */
const processor = BackgroundBlurFactory.deserializeProcessor( const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized userChoices.processorSerialized
) )

View File

@@ -28,6 +28,14 @@ export const text = cva({
paddingTop: 'heading', paddingTop: 'heading',
}, },
}, },
subTitle: {
fontSize: '1rem',
color: 'greyscale.600',
},
bodyXsBold: {
textStyle: 'body',
fontWeight: 'bold',
},
body: { body: {
textStyle: 'body', textStyle: 'body',
}, },