(frontend) add custom virtual background feature

Add a custom virtual background feature.

If the backend supports uploading files, backgrounds are stored
in the backend for the user.
Otherwise, only one background image can be selected.
This commit is contained in:
Florent Chehab
2026-03-03 17:49:53 +01:00
parent fcad79d662
commit 16daf7b8d3
29 changed files with 1133 additions and 427 deletions
+1
View File
@@ -65,6 +65,7 @@ and this project adheres to
### Added ### Added
- ✨(backend) add file upload feature #1030 - ✨(backend) add file upload feature #1030
- ✨(frontend) custom background #1067
## [1.9.0] - 2026-03-02 ## [1.9.0] - 2026-03-02
+1 -1
View File
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000 AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083 MEDIA_BASE_URL=http://localhost:3000
FILE_UPLOAD_ENABLED=True FILE_UPLOAD_ENABLED=True
# OIDC # OIDC
+13 -1
View File
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers, ...options?.headers,
}, },
}) })
const result = await response.json()
let result: T
if (response.status === 204) {
result = undefined as T
} else {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
result = undefined as T
} else {
result = (await response.json()) as T
}
}
if (!response.ok) { if (!response.ok) {
throw new ApiError(response.status, result) throw new ApiError(response.status, result)
} }
+1
View File
@@ -5,4 +5,5 @@ export const keys = {
requestEntry: 'requestEntry', requestEntry: 'requestEntry',
waitingParticipants: 'waitingParticipants', waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback', roomCreationCallback: 'roomCreationCallback',
files: 'files',
} }
+7
View File
@@ -30,6 +30,13 @@ export interface ApiConfig {
expiration_days?: number expiration_days?: number
max_duration?: number max_duration?: number
} }
background_image: {
upload_is_enabled: boolean
max_size: number
max_count_by_user: number
allowed_extensions: string[]
allowed_mimetypes: string[]
}
subtitle: { subtitle: {
enabled: boolean enabled: boolean
} }
@@ -0,0 +1,90 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ApiFileItem } from '@/features/files/api/types.ts'
import { keys } from '@/api/queryKeys.ts'
/**
* Upload a file, using XHR so we can report on progress through a handler.
*
* @param url The URL to PUT the file to.
* @param file The file to upload.
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
*/
export const uploadFile = (
url: string,
file: File,
progressHandler: (progress: number) => void
) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', url)
xhr.setRequestHeader('X-amz-acl', 'private')
xhr.setRequestHeader('Content-Type', file.type)
xhr.addEventListener('error', reject)
xhr.addEventListener('abort', reject)
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
// Make sure to always set the progress to 100% when the upload is done.
// Because 'progress' event listener is not called when the file size is 0.
progressHandler(100)
return resolve(true)
}
reject(new Error(`Failed to perform the upload on ${url}.`))
}
})
xhr.upload.addEventListener('progress', (progressEvent) => {
if (progressEvent.lengthComputable) {
progressHandler(
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
)
}
})
xhr.send(file)
})
/**
* Asynchronously creates a new file and uploads it to the server.
*
* @param {object} params - The parameters for the file creation and upload process.
* @param {File} params.file - The file object to be uploaded.
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
*/
export const createFile = async ({
file,
onProgress,
}: {
file: File
onProgress: (progress: number) => void
}): Promise<ApiFileItem> => {
const res = await fetchApi<ApiFileItem>(`/files/`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
})
if (res.upload_state !== 'pending') {
throw new Error('State should be pending right after creation')
}
const policy = res.policy
await uploadFile(policy, file, onProgress)
return await fetchApi<ApiFileItem>(`/files/${res.id}/upload-ended/`, {
method: 'POST',
})
}
export const useCreateFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createFile,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [keys.files],
})
},
})
}
@@ -0,0 +1,33 @@
import { fetchApi } from '@/api/fetchApi'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys.ts'
/**
* Deletes a file specified by its unique identifier.
*
* @param {Object} params - The parameters required for deleting the file.
* @param {string} params.fileId - The unique identifier of the file to be deleted.
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
*/
export const deleteFile = async ({
fileId,
}: {
fileId: string
}): Promise<void> => {
await fetchApi<void>(`/files/${fileId}/`, {
method: 'DELETE',
})
}
export const useDeleteFile = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteFile,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: [keys.files],
})
},
})
}
@@ -0,0 +1,70 @@
import { fetchApi } from '@/api/fetchApi'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import {
ApiFileItem,
ApiFileType,
ApiFileUploadState,
} from '@/features/files/api/types.ts'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig.ts'
type ListFilesResponse = {
count: number
next: string | null
previous: string | null
results: ApiFileItem[]
}
type ListFilesFilters = {
is_creator_me?: boolean
type?: ApiFileType
upload_state?: ApiFileUploadState
is_deleted?: boolean
}
export type ListFilesParams = {
filters?: ListFilesFilters
pagination: {
page: number
pageSize: number
}
}
export const listMyFiles = async ({
filters = {},
pagination: { page, pageSize },
}: ListFilesParams): Promise<ListFilesResponse> => {
const query = new URLSearchParams()
query.append('page', page.toString())
query.append('page_size', pageSize.toString())
if (filters?.is_creator_me ?? true) {
query.append('is_creator_me', 'true')
}
if (filters?.type) {
query.append('type', filters.type)
}
if (filters?.upload_state) {
query.append('upload_state', filters.upload_state)
}
if (typeof filters?.is_deleted === 'boolean') {
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
}
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
method: 'GET',
})
}
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
const { isLoggedIn } = useUser()
const { data: appConfig } = useConfig()
return useQuery({
queryKey: [keys.files, params],
queryFn: () => listMyFiles(params),
refetchOnMount: 'always',
placeholderData: keepPreviousData,
enabled:
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
})
}
@@ -0,0 +1,34 @@
export type ApiFileCreator = {
id: string // UUID
full_name: string | null
short_name: string | null
}
export type ApiFileType = 'background_image'
export type ApiFileUploadState = 'pending' | 'ready'
export type ApiFileItem = {
id: string // UUID
created_at: string // ISO datetime string
updated_at: string // ISO datetime string
title: string
type: ApiFileType
creator: ApiFileCreator
deleted_at: string | null
hard_deleted_at: string | null
filename: string
upload_state: ApiFileUploadState
mimetype: string // e.g. "image/png"
size: number // file size in bytes
description: string | null
} & (
| {
upload_state: 'ready'
url: string
}
| {
upload_state: 'pending'
policy: string
url: null
}
)
@@ -215,8 +215,8 @@ export const Conference = ({
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={ video={
userConfig.videoEnabled && { userConfig.videoEnabled && {
processor: BackgroundProcessorFactory.deserializeProcessor( processor: BackgroundProcessorFactory.fromProcessorConfig(
userConfig.processorSerialized userConfig.processorConfig
), ),
} }
} }
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { import {
createLocalVideoTrack,
createLocalAudioTrack, createLocalAudioTrack,
createLocalVideoTrack,
LocalAudioTrack, LocalAudioTrack,
LocalVideoTrack, LocalVideoTrack,
Track, Track,
} from 'livekit-client' } from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field' import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives' import { Button, Dialog, Form, Text } from '@/primitives'
import { VStack } from '@/styled-system/jsx' import { VStack } from '@/styled-system/jsx'
import { Heading } from 'react-aria-components' import { Heading } from 'react-aria-components'
import { RiImageCircleAiFill } from '@remixicon/react' import { RiImageCircleAiFill } from '@remixicon/react'
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
const Effects = ({ const Effects = ({
videoTrack, videoTrack,
onSubmit, }: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' }) const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const openDialog = () => setIsDialogOpen(true) const openDialog = () => setIsDialogOpen(true)
@@ -81,7 +80,7 @@ const Effects = ({
> >
{t('subTitle')} {t('subTitle')}
</Text> </Text>
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} /> <EffectsConfiguration videoTrack={videoTrack} />
</Dialog> </Dialog>
<Button <Button
variant="whiteCircle" variant="whiteCircle"
@@ -111,7 +110,7 @@ export const Join = ({
audioDeviceId, audioDeviceId,
audioOutputDeviceId, audioOutputDeviceId,
videoDeviceId, videoDeviceId,
processorSerialized, processorConfig,
username, username,
}, },
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -120,7 +119,6 @@ export const Join = ({
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveVideoInputDeviceId, saveVideoInputDeviceId,
saveUsername, saveUsername,
saveProcessorSerialized,
} = usePersistentUserChoices() } = usePersistentUserChoices()
const initialUserChoices = useRef<LocalUserChoices | null>(null) const initialUserChoices = useRef<LocalUserChoices | null>(null)
@@ -132,7 +130,7 @@ export const Join = ({
audioDeviceId, audioDeviceId,
audioOutputDeviceId, audioOutputDeviceId,
videoDeviceId, videoDeviceId,
processorSerialized, processorConfig,
username, username,
} }
} }
@@ -146,8 +144,8 @@ export const Join = ({
video: !!initialUserChoices.current && video: !!initialUserChoices.current &&
initialUserChoices.current?.videoEnabled && { initialUserChoices.current?.videoEnabled && {
deviceId: initialUserChoices.current.videoDeviceId, deviceId: initialUserChoices.current.videoDeviceId,
processor: BackgroundProcessorFactory.deserializeProcessor( processor: BackgroundProcessorFactory.fromProcessorConfig(
initialUserChoices.current.processorSerialized initialUserChoices.current.processorConfig
), ),
}, },
}, },
@@ -186,9 +184,7 @@ export const Join = ({
const track = await createLocalVideoTrack({ const track = await createLocalVideoTrack({
deviceId: { exact: videoDeviceId }, deviceId: { exact: videoDeviceId },
processor: processor:
BackgroundProcessorFactory.deserializeProcessor( BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
processorSerialized
),
}) })
setDynamicVideoTrack(track) setDynamicVideoTrack(track)
} catch (error) { } catch (error) {
@@ -207,7 +203,7 @@ export const Join = ({
}, [ }, [
videoEnabled, videoEnabled,
videoDeviceId, videoDeviceId,
processorSerialized, processorConfig,
previewVideoTrack, previewVideoTrack,
dynamicVideoTrack, dynamicVideoTrack,
]) ])
@@ -690,12 +686,7 @@ export const Join = ({
zIndex: '1', zIndex: '1',
})} })}
> >
<Effects <Effects videoTrack={videoTrack} />
videoTrack={videoTrack}
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -11,11 +11,7 @@ import {
TIMEOUT_TICK, TIMEOUT_TICK,
timerWorkerScript, timerWorkerScript,
} from './TimerWorker' } from './TimerWorker'
import { import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
const PROCESSING_WIDTH = 256 const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144 const PROCESSING_HEIGHT = 144
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
* MediaStreamTrackProcessor. * MediaStreamTrackProcessor.
*/ */
export class BackgroundCustomProcessor implements BackgroundProcessorInterface { export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions options: ProcessorConfig
name: string name: string
processedTrack?: MediaStreamTrack | undefined processedTrack?: MediaStreamTrack | undefined
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
type: ProcessorType type: ProcessorType
virtualBackgroundImage?: HTMLImageElement virtualBackgroundImage?: HTMLImageElement
constructor(opts: BackgroundOptions) { constructor(opts: ProcessorConfig) {
this.name = 'blur' this.name = 'blur'
this.options = opts this.options = opts
this.type = opts.type
if (this.options.blurRadius) {
this.type = ProcessorType.BLUR
} else {
this.type = ProcessorType.VIRTUAL
}
} }
static get isSupported() { static get isSupported() {
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
} }
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') {
throw new Error(
'Virtual background is only supported for virtual background'
)
}
const needsUpdate = const needsUpdate =
this.options.imagePath && this.options.imagePath &&
this.virtualBackgroundImage && this.virtualBackgroundImage &&
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
} }
} }
async update(opts: BackgroundOptions): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
this.options = opts this.options = opts
this._initVirtualBackgroundImage() this._initVirtualBackgroundImage()
} }
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
* TODO: future improvement with WebGL. * TODO: future improvement with WebGL.
*/ */
async blur() { async blur() {
if (this.options.type !== 'blur') {
throw new Error('Blurring is only supported for blur background')
}
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array() const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
for (let i = 0; i < mask.length; ++i) { for (let i = 0; i < mask.length; ++i) {
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i] this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.sizeSource() await this.sizeSource()
await this.segment() await this.segment()
if (this.options.blurRadius) { if (this.options.type === 'blur') {
await this.blur() await this.blur()
} else { } else {
await this.drawVirtualBackground() await this.drawVirtualBackground()
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
this.timerWorker?.terminate() this.timerWorker?.terminate()
this.imageSegmenter?.close() this.imageSegmenter?.close()
} }
clone() {
return new BackgroundCustomProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
} }
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
this.timerWorker?.terminate() this.timerWorker?.terminate()
this.faceLandmarker?.close() this.faceLandmarker?.close()
} }
clone() {
return new FaceLandmarksProcessor(this.options)
}
serialize() {
return {
type: this.type,
options: this.options,
}
}
} }
@@ -1,28 +1,23 @@
import { ProcessorOptions, Track } from 'livekit-client' import { ProcessorOptions, Track } from 'livekit-client'
import { import {
BackgroundBlur, BackgroundBlur,
BackgroundTransformer,
ProcessorWrapper, ProcessorWrapper,
VirtualBackground, VirtualBackground,
} from '@livekit/track-processors' } from '@livekit/track-processors'
import { import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
BackgroundOptions,
BackgroundProcessorInterface,
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface { export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
processor: ProcessorWrapper<BackgroundOptions> processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
opts: BackgroundOptions opts: ProcessorConfig
processorType: ProcessorType processorType: ProcessorType
constructor(opts: BackgroundOptions) { constructor(opts: ProcessorConfig) {
this.opts = opts this.opts = opts
if (opts.imagePath) { if (opts.type === 'virtual') {
this.processorType = ProcessorType.VIRTUAL this.processorType = ProcessorType.VIRTUAL
this.processor = VirtualBackground(opts.imagePath) this.processor = VirtualBackground(opts.imagePath)
} else if (opts.blurRadius !== undefined) { } else if (opts.type === 'blur') {
this.processorType = ProcessorType.BLUR this.processorType = ProcessorType.BLUR
this.processor = BackgroundBlur(opts.blurRadius) this.processor = BackgroundBlur(opts.blurRadius)
} else { } else {
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
return this.processor.destroy() return this.processor.destroy()
} }
async update(opts: BackgroundOptions): Promise<void> { async update(opts: ProcessorConfig): Promise<void> {
const newProcessorType = opts.imagePath this.opts = opts
? ProcessorType.VIRTUAL
: ProcessorType.BLUR const newProcessorType =
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
let processedOpts = opts
if (newProcessorType !== this.processorType) { if (newProcessorType !== this.processorType) {
this.processorType = newProcessorType this.processorType = newProcessorType
if (newProcessorType === ProcessorType.VIRTUAL) { if (newProcessorType === ProcessorType.VIRTUAL) {
this.processor.name = 'virtual-background' this.processor.name = 'virtual-background'
processedOpts = { ...opts, blurRadius: undefined }
} else { } else {
this.processor.name = 'background-blur' this.processor.name = 'background-blur'
processedOpts = { ...opts, imagePath: undefined }
} }
} }
await this.processor.updateTransformerOptions(processedOpts) await this.processor.updateTransformerOptions(
this.opts = processedOpts opts as { imagePath?: string; blurRadius?: number }
)
} }
get name() { get name() {
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
} }
get options() { get options() {
return (this.processor.transformer as BackgroundTransformer).options return this.opts
}
clone() {
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
}
serialize() {
return {
type: this.processorType,
options: this.options,
}
} }
} }
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
import { Track, TrackProcessor } from 'livekit-client' import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor' import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor' import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
}
export interface ProcessorSerialized {
type: ProcessorType
options: BackgroundOptions
}
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
serialize(): ProcessorSerialized
}
export enum ProcessorType { export enum ProcessorType {
BLUR = 'blur', BLUR = 'blur',
@@ -26,6 +10,16 @@ export enum ProcessorType {
FACE_LANDMARKS = 'faceLandmarks', FACE_LANDMARKS = 'faceLandmarks',
} }
export type ProcessorConfig =
| { type: ProcessorType.BLUR; blurRadius: number }
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
update(opts: ProcessorConfig): Promise<void>
options: ProcessorConfig
}
export class BackgroundProcessorFactory { export class BackgroundProcessorFactory {
static hasModernApiSupport() { static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport return ProcessorWrapper.hasModernApiSupport
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
} }
static getProcessor( static getProcessor(
type: ProcessorType, config: ProcessorConfig
opts: BackgroundOptions
): BackgroundProcessorInterface | undefined { ): BackgroundProcessorInterface | undefined {
const isBlur = type === ProcessorType.BLUR const isBlur = config.type === ProcessorType.BLUR
const isVirtual = type === ProcessorType.VIRTUAL const isVirtual = config.type === ProcessorType.VIRTUAL
if (!isBlur && !isVirtual) return undefined if (!isBlur && !isVirtual) return undefined
if (ProcessorWrapper.isSupported) { if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(opts) return new UnifiedBackgroundTrackProcessor(config)
} }
if (BackgroundCustomProcessor.isSupported) { if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts) return new BackgroundCustomProcessor(config)
} }
return undefined return undefined
} }
static deserializeProcessor(data?: ProcessorSerialized) { static fromProcessorConfig(data?: ProcessorConfig) {
if (data?.type) { if (data) {
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options) return BackgroundProcessorFactory.getProcessor(data)
} }
return undefined return undefined
} }
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
* *
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121 * See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/ */
const processor = BackgroundProcessorFactory.deserializeProcessor( const processor = BackgroundProcessorFactory.fromProcessorConfig(
userChoices.processorSerialized userChoices.processorConfig
) )
const toggle = trackProps.toggle as ( const toggle = trackProps.toggle as (
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
import { LocalVideoTrack } from 'livekit-client' import { LocalVideoTrack } from 'livekit-client'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { EffectsConfiguration } from './EffectsConfiguration' import { EffectsConfiguration } from './EffectsConfiguration'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack' import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
import { TrackSource } from '@livekit/protocol' import { TrackSource } from '@livekit/protocol'
export const Effects = () => { export const Effects = () => {
const { cameraTrack } = useLocalParticipant() const { cameraTrack } = useLocalParticipant()
const localCameraTrack = cameraTrack?.track as LocalVideoTrack const localCameraTrack = cameraTrack?.track as LocalVideoTrack
const { saveProcessorSerialized } = usePersistentUserChoices()
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA) const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
@@ -24,9 +22,6 @@ export const Effects = () => {
isDisabled={!canPublishCamera} isDisabled={!canPublishCamera}
videoTrack={localCameraTrack} videoTrack={localCameraTrack}
layout="vertical" layout="vertical"
onSubmit={(processor) =>
saveProcessorSerialized(processor?.serialize())
}
/> />
</div> </div>
) )
@@ -1,16 +1,16 @@
import { LocalVideoTrack, Track } from 'livekit-client' import { LocalVideoTrack, Track } from 'livekit-client'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
BackgroundOptions,
BackgroundProcessorFactory, BackgroundProcessorFactory,
BackgroundProcessorInterface, BackgroundProcessorInterface,
ProcessorConfig,
ProcessorType, ProcessorType,
} from '../blur' } from '../blur'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives' import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip' import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx' import { HStack, styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn' import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong' import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react' import { useTrackToggle } from '@livekit/components-react'
@@ -19,6 +19,19 @@ import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { FunnyEffects } from './FunnyEffects' import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess' import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import {
ListFilesParams,
useListMyFiles,
} from '@/features/files/api/listFiles.ts'
import { useCreateFile } from '@/features/files/api/createFile.ts'
import { FileTrigger } from 'react-aria-components'
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react'
import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
import { useUser } from '@/features/auth'
import { ApiFileItem } from '@/features/files/api/types.ts'
import { useConfig } from '@/api/useConfig.ts'
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices.ts'
import { proxy, useSnapshot } from 'valtio'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
@@ -40,14 +53,51 @@ const Information = styled('div', {
export type EffectsConfigurationProps = { export type EffectsConfigurationProps = {
isDisabled?: boolean isDisabled?: boolean
videoTrack: LocalVideoTrack videoTrack: LocalVideoTrack
onSubmit?: (processor?: BackgroundProcessorInterface) => void
layout?: 'vertical' | 'horizontal' layout?: 'vertical' | 'horizontal'
} }
const listFilesQueryParams: ListFilesParams = {
filters: {
type: 'background_image',
upload_state: 'ready',
is_creator_me: true,
is_deleted: false,
},
pagination: {
page: 1,
pageSize: 20,
},
}
function deriveIdFromProcessorConfig(config: ProcessorConfig) {
if (config.type === ProcessorType.BLUR) {
return `blur-${config.blurRadius}`
} else if (config.type === ProcessorType.VIRTUAL) {
// the imagePath is not stable for custom backgrounds
// so we try first with the fileId
if (config.fileId) {
return `virtual-${config.fileId}`
}
return `virtual-${config.imagePath}`
} else if (config.type === ProcessorType.FACE_LANDMARKS) {
return 'face-landmarks'
}
throw new Error(`Unknown config type in config: ${config}`)
}
// We use a valtio store so that the state is persisted between the join room
// and the actual room
const uploadNotPossibleLocalState = proxy({
imageBackgroundConfig: null as null | {
type: ProcessorType.VIRTUAL
imagePath: string
label: string
},
})
export const EffectsConfiguration = ({ export const EffectsConfiguration = ({
isDisabled, isDisabled,
videoTrack, videoTrack,
onSubmit,
layout = 'horizontal', layout = 'horizontal',
}: EffectsConfigurationProps) => { }: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
@@ -63,6 +113,365 @@ export const EffectsConfiguration = ({
> | null>(null) > | null>(null)
const effectAnnouncementId = useRef(0) const effectAnnouncementId = useRef(0)
const {
saveProcessorConfig,
userChoices: { processorConfig },
} = usePersistentUserChoices()
const selectedId = useMemo(
() =>
processorConfig ? deriveIdFromProcessorConfig(processorConfig) : 'none',
[processorConfig]
)
const uploadNotPossibleSnap = useSnapshot(uploadNotPossibleLocalState)
const announceEffectStatusMessage = useCallback(
(message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
announce(message)
}, 80)
},
[announce]
)
const getVirtualBackgroundName = useCallback(
(imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.presets.descriptions.${index}`)
},
[t]
)
const updateEffectStatusMessage = useCallback(
(config: ProcessorConfig, wasSelectedBeforeToggle: boolean) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (config.type === ProcessorType.BLUR) {
const message =
config.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (config.type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(config.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
},
[announceEffectStatusMessage, getVirtualBackgroundName, t]
)
const toggleEffect = useCallback(
async (config: ProcessorConfig) => {
setProcessorPending(true)
const wasSelectedBeforeToggle =
selectedId === deriveIdFromProcessorConfig(config)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
* setProcessor on.
*
* We arrive in this condition when we enter the room with the camera already off.
*/
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)!
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
if (!enabled) {
await toggle(true)
}
const processor =
videoTrack?.getProcessor() as BackgroundProcessorInterface
try {
if (wasSelectedBeforeToggle) {
// Stop processor.
await videoTrack.stopProcessor()
saveProcessorConfig(undefined)
} else if (
!processor ||
(processor.options.type !== config.type &&
!BackgroundProcessorFactory.hasModernApiSupport())
) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(config)!
// IMPORTANT: Must explicitly stop previous processor before setting a new one
// in browsers without modern API support to prevent UI crashes.
// This workaround is needed until this issue is resolved:
// https://github.com/livekit/track-processors-js/issues/85
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
await videoTrack.stopProcessor()
}
await videoTrack.setProcessor(newProcessor)
saveProcessorConfig(config)
} else {
await processor?.update(config)
saveProcessorConfig(config)
}
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
}
},
[
enabled,
saveProcessorConfig,
selectedId,
toggle,
updateEffectStatusMessage,
videoTrack,
]
)
const { data: appConfig } = useConfig()
const { isLoggedIn } = useUser()
const canUploadBackground =
isLoggedIn === true &&
appConfig?.background_image?.upload_is_enabled === true
// We split the error state in 2 parts so that there are no visual glitches when closing the alert
const [personalBackgroundHasError, setPersonalBackgroundHasError] =
useState<boolean>(false)
const [personalBackgroundError, setPersonalBackgroundError] = useState<
'file_too_large' | 'invalid_file_type' | null
>(null)
const createFileMutation = useCreateFile()
const deleteFileMutation = useDeleteFile()
const filesQ = useListMyFiles(listFilesQueryParams)
const hasReachedMaxNbBackgrounds =
(canUploadBackground &&
appConfig &&
filesQ.data &&
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
false
const getHandleSelectChangeFile = useCallback(
(file: ApiFileItem) => {
return async () => {
await toggleEffect({
type: ProcessorType.VIRTUAL,
imagePath: file.url!,
fileId: file.id,
})
}
},
[toggleEffect]
)
const handleNewBackgroundFilePicked = useCallback(
async (file: File) => {
if (
!(
appConfig?.background_image?.allowed_mimetypes?.includes(file.type) ??
false
)
) {
setPersonalBackgroundError('invalid_file_type')
setPersonalBackgroundHasError(true)
return
}
if (file.size > (appConfig?.background_image?.max_size ?? 0)) {
setPersonalBackgroundError('file_too_large')
setPersonalBackgroundHasError(true)
return
}
// When the user is not logged in, we fallback to just loading that image
if (!canUploadBackground) {
// For the preview to work, we somehow need to create a data URL from the raw file.
// revoking is handled with userChoicesStore
if (uploadNotPossibleSnap.imageBackgroundConfig) {
URL.revokeObjectURL(
uploadNotPossibleSnap.imageBackgroundConfig.imagePath
)
}
const imagePath = URL.createObjectURL(file)
const fileId = `local-image`
await toggleEffect({
type: ProcessorType.VIRTUAL,
imagePath,
fileId,
})
uploadNotPossibleLocalState.imageBackgroundConfig = {
type: ProcessorType.VIRTUAL,
label: file.name.split('.')[0],
imagePath,
}
} else {
// Otherwise we create the file in the backend and automatically select it
// when it's uploaded.
createFileMutation.mutate(
{
file,
onProgress: (progress) => {
console.debug('upload-progress', progress)
},
},
{
onSuccess: (file) => {
// We automatically select that created file
getHandleSelectChangeFile(file)()
},
}
)
}
},
[
appConfig?.background_image?.allowed_mimetypes,
appConfig?.background_image?.max_size,
canUploadBackground,
createFileMutation,
getHandleSelectChangeFile,
toggleEffect,
uploadNotPossibleSnap.imageBackgroundConfig,
]
)
const filePickerErrorContext = useMemo(
() => ({
allowedExtension: appConfig?.background_image?.allowed_extensions ?? [],
maxSize: (appConfig?.background_image?.max_size ?? 0) / (1024 * 1024),
}),
[appConfig?.background_image]
)
const processorOptions = useMemo<{
isDisabled: boolean
blurBased: {
radius: BlurRadius
Icon: React.FC
ref?: React.Ref<HTMLButtonElement>
tooltip: string
id: string
config: ProcessorConfig
isSelected: boolean
}[]
virtualBackgrounds: {
id: string
config: ProcessorConfig
isSelected: boolean
tooltip: string
ariaLabel: string
thumbnailPath: string
index: number
}[]
remoteCustomVirtualBackgrounds: {
id: string
config: ProcessorConfig
isSelected: boolean
tooltip: string
file: ApiFileItem
}[]
}>(() => {
return {
isDisabled: (processorPendingReveal || isDisabled) ?? false,
blurBased: [
{
key: 'light',
radius: BlurRadius.LIGHT,
icon: BlurOn,
ref: blurLightRef,
},
{
key: 'normal',
radius: BlurRadius.NORMAL,
icon: BlurOnStrong,
ref: undefined,
},
].map((item) => {
const config: ProcessorConfig = {
type: ProcessorType.BLUR,
blurRadius: item.radius,
}
const id = deriveIdFromProcessorConfig(config)
return {
id,
tooltip: t(`blur.light.${selectedId === id ? 'clear' : 'apply'}`),
radius: item.radius,
isSelected: selectedId === id,
Icon: item.icon,
ref: item.ref,
config,
}
}),
virtualBackgrounds: [...Array(8).keys()].map((index) => {
const imagePath = `/assets/backgrounds/${index + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${index + 1}.jpg`
const config: ProcessorConfig = {
type: ProcessorType.VIRTUAL,
imagePath,
}
const id = deriveIdFromProcessorConfig(config)
const isSelected = selectedId === id
const prefix = isSelected ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.presets.descriptions.${index}`)
const ariaLabel = `${t(`virtual.presets.${prefix}`)} ${backgroundName}`
return {
tooltip: backgroundName,
id,
config,
isSelected: selectedId === id,
thumbnailPath,
ariaLabel,
index,
}
}),
remoteCustomVirtualBackgrounds: (filesQ.data?.results ?? [])
.filter((file) => file.url)
.map((file) => {
const config: ProcessorConfig = {
type: ProcessorType.VIRTUAL,
imagePath: file.url!,
fileId: file.id,
}
const id = deriveIdFromProcessorConfig(config)
return {
tooltip: file.title,
id,
config,
isSelected: selectedId === id,
file,
}
}),
}
}, [processorPendingReveal, isDisabled, filesQ.data?.results, t, selectedId])
useEffect(() => { useEffect(() => {
const videoElement = videoRef.current const videoElement = videoRef.current
if (!videoElement) return if (!videoElement) return
@@ -97,171 +506,6 @@ export const EffectsConfiguration = ({
[] []
) )
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
announce(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
* setProcessor on.
*
* We arrive in this condition when we enter the room with the camera already off.
*/
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(
type,
options
)!
await toggle(true, {
processor: newProcessorTmp,
})
setTimeout(() => setProcessorPending(false))
return
}
if (!enabled) {
await toggle(true)
}
const processor = getProcessor()
try {
if (wasSelectedBeforeToggle) {
// Stop processor.
await clearEffect()
} else if (
!processor ||
(processor.serialize().type !== type &&
!BackgroundProcessorFactory.hasModernApiSupport())
) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(
type,
options
)!
// IMPORTANT: Must explicitly stop previous processor before setting a new one
// in browsers without modern API support to prevent UI crashes.
// This workaround is needed until this issue is resolved:
// https://github.com/livekit/track-processors-js/issues/85
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
await videoTrack.stopProcessor()
}
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
await processor?.update(options)
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
}
}
const getProcessor = () => {
return videoTrack?.getProcessor() as BackgroundProcessorInterface
}
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
const processor = getProcessor()
const processorSerialized = processor?.serialize()
return (
!!processor &&
processorSerialized.type === type &&
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
)
}
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
}
return ( return (
<div <div
className={css( className={css(
@@ -362,7 +606,7 @@ export const EffectsConfiguration = ({
<div> <div>
<div> <div>
<H <H
lvl={3} lvl={2}
style={{ style={{
marginBottom: '1rem', marginBottom: '1rem',
}} }}
@@ -377,65 +621,208 @@ export const EffectsConfiguration = ({
gap: '1.25rem', gap: '1.25rem',
})} })}
> >
<ToggleButton {processorOptions.blurBased.map(({ Icon, ...option }) => (
ref={blurLightRef} <ToggleButton
variant="bigSquare" key={option.id}
aria-label={tooltipBlur(ProcessorType.BLUR, { ref={option.ref}
blurRadius: BlurRadius.LIGHT, variant="bigSquare"
})} aria-label={option.tooltip}
tooltip={tooltipBlur(ProcessorType.BLUR, { tooltip={option.tooltip}
blurRadius: BlurRadius.LIGHT, isDisabled={processorOptions.isDisabled}
})} onChange={() => toggleEffect(option.config)}
isDisabled={processorPendingReveal || isDisabled} isSelected={option.isSelected}
onChange={async () => data-attr={`toggle-${option.id}`}
await toggleEffect(ProcessorType.BLUR, { >
blurRadius: BlurRadius.LIGHT, <Icon />
}) </ToggleButton>
} ))}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div> </div>
</div> </div>
<div
className={css({ <div className={css({ marginTop: '1.5rem' })}>
marginTop: '1.5rem',
})}
>
<H <H
lvl={3} lvl={2}
style={{ style={{
marginBottom: '1rem', marginBottom: '0.6rem',
}} }}
variant="bodyXsBold" variant="bodyXsBold"
> >
{t('virtual.title')} {t('virtual.title')}
</H> </H>
</div>
<div
className={css({
marginBottom: '1rem',
})}
>
<H
lvl={2}
style={{
marginBottom: '0.4rem',
}}
variant="bodyXsMedium"
>
{t('virtual.personal.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{canUploadBackground &&
processorOptions.remoteCustomVirtualBackgrounds.map(
(option) => (
<div
key={option.id}
className={
'hoverGroup ' + css({ position: 'relative' })
}
>
<VisualOnlyTooltip tooltip={option.tooltip}>
<ToggleButton
variant="bigSquare"
aria-label={option.tooltip}
isDisabled={processorOptions.isDisabled}
onChange={getHandleSelectChangeFile(option.file)}
isSelected={option.isSelected}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${option.file.url!})`,
}}
data-attr={`toggle-virtual-${option.file.id}`}
/>
</VisualOnlyTooltip>
<Button
className={
'hoverGroupChild ' +
css({
position: 'absolute',
top: '-8px',
right: '-8px',
transition: 'opacity 0.2s ease-in-out',
})
}
size={'xs'}
variant={'tertiary'}
onClick={() => {
if (option.isSelected) {
// we remove the current effect
toggleEffect(option.config)
}
deleteFileMutation.mutate({
fileId: option.file.id,
})
}}
isDisabled={deleteFileMutation.isPending}
>
<RiDeleteBinLine size={16} />
</Button>
</div>
)
)}
{!canUploadBackground &&
uploadNotPossibleSnap.imageBackgroundConfig && (
<VisualOnlyTooltip
tooltip={
uploadNotPossibleSnap.imageBackgroundConfig.label
}
>
<ToggleButton
variant="bigSquare"
aria-label={
uploadNotPossibleSnap.imageBackgroundConfig.label
}
isDisabled={processorOptions.isDisabled}
onChange={() => {
toggleEffect(
uploadNotPossibleSnap.imageBackgroundConfig!
)
}}
isSelected={
deriveIdFromProcessorConfig(
uploadNotPossibleSnap.imageBackgroundConfig
) === selectedId
}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`,
}}
data-attr={`toggle-virtual-local`}
/>
</VisualOnlyTooltip>
)}
<FileTrigger
acceptedFileTypes={
appConfig?.background_image?.allowed_mimetypes ?? [
'image/png',
'image/jpeg',
]
}
onSelect={(e) => {
if (e && e.item(0)) {
const file = e.item(0) as File
handleNewBackgroundFilePicked(file)
}
}}
>
<Button
variant="bigSquare"
aria-label={t('virtual.personal.selectFileTooltip')}
tooltip={t('virtual.personal.selectFileTooltip')}
isDisabled={
(canUploadBackground &&
filesQ.data &&
filesQ.data.count >=
(appConfig?.background_image?.max_count_by_user ??
0)) ||
processorOptions.isDisabled ||
createFileMutation.isPending
}
data-attr="input-file-select-personal-background"
>
<RiImageAddFill />
</Button>
</FileTrigger>
</div>
{!isLoggedIn && (
<Text variant="xsNote">
{t('virtual.personal.notLoggedInWarning')}
</Text>
)}
{!canUploadBackground && isLoggedIn && (
<Text variant="xsNote">
{t('virtual.personal.warningUploadDisabled')}
</Text>
)}
{hasReachedMaxNbBackgrounds && (
<Text variant="xsNote">
{t('virtual.personal.uploadLimitReached')}
</Text>
)}
</div>
<div
className={css({
marginTop: '0.4rem',
})}
>
<H
lvl={2}
style={{
marginBottom: '0.4rem',
}}
variant="bodyXsMedium"
>
{t('virtual.presets.title')}
</H>
<div <div
className={css({ className={css({
display: 'flex', display: 'flex',
@@ -444,35 +831,24 @@ export const EffectsConfiguration = ({
flexWrap: 'wrap', flexWrap: 'wrap',
})} })}
> >
{[...Array(8).keys()].map((i) => { {processorOptions.virtualBackgrounds.map((option) => (
const imagePath = `/assets/backgrounds/${i + 1}.jpg` <VisualOnlyTooltip key={option.id} tooltip={option.tooltip}>
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg` <ToggleButton
const tooltipText = tooltipVirtualBackground(i) variant="bigSquare"
return ( aria-label={option.ariaLabel}
<VisualOnlyTooltip key={i} tooltip={tooltipText}> isDisabled={processorOptions.isDisabled}
<ToggleButton onChange={() => toggleEffect(option.config)}
variant="bigSquare" isSelected={option.isSelected}
aria-label={ariaLabelVirtualBackground(i, imagePath)} className={css({
isDisabled={processorPendingReveal || isDisabled} bgSize: 'cover',
onChange={async () => })}
await toggleEffect(ProcessorType.VIRTUAL, { style={{
imagePath, backgroundImage: `url(${option.thumbnailPath})`,
}) }}
} data-attr={`toggle-virtual-preset-${option.index}`}
isSelected={isSelected(ProcessorType.VIRTUAL, { />
imagePath, </VisualOnlyTooltip>
})} ))}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
)
})}
</div> </div>
</div> </div>
</div> </div>
@@ -483,6 +859,32 @@ export const EffectsConfiguration = ({
</Information> </Information>
)} )}
</div> </div>
<Dialog
isOpen={personalBackgroundHasError}
type="alert"
title={t(`virtual.personal.errors.${personalBackgroundError}.title`)}
aria-label={t(
`virtual.personal.errors.${personalBackgroundError}.title`
)}
onClose={() => setPersonalBackgroundHasError(false)}
onOpenChange={() => setPersonalBackgroundHasError(false)}
>
<P>
{t(
`virtual.personal.errors.${personalBackgroundError}.description`,
filePickerErrorContext
)}
</P>
<HStack justifyContent="end" direction="row">
<Button
variant="text"
size="sm"
onPress={() => setPersonalBackgroundHasError(false)}
>
{t('virtual.personal.errors.close')}
</Button>
</HStack>
</Dialog>
</div> </div>
) )
} }
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
showFrench: false, showFrench: false,
} }
} }
return processor.serialize().options return { ...processor.options }
} }
const options = getOptions() const options = getOptions()
@@ -1,7 +1,7 @@
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices' import { userChoicesStore } from '@/stores/userChoices'
import type { VideoResolution } from '@/stores/userChoices' import type { VideoResolution } from '@/stores/userChoices'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
import type { VideoQuality } from 'livekit-client' import type { VideoQuality } from 'livekit-client'
export function usePersistentUserChoices() { export function usePersistentUserChoices() {
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
saveNoiseReductionEnabled: (enabled: boolean) => { saveNoiseReductionEnabled: (enabled: boolean) => {
userChoicesStore.noiseReductionEnabled = enabled userChoicesStore.noiseReductionEnabled = enabled
}, },
saveProcessorSerialized: ( saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
processorSerialized: ProcessorSerialized | undefined userChoicesStore.processorConfig = processorConfig
) => {
userChoicesStore.processorSerialized = processorSerialized
}, },
} }
} }
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
const { const {
userChoices: { userChoices: {
videoDeviceId, videoDeviceId,
processorSerialized, processorConfig,
videoPublishResolution, videoPublishResolution,
videoSubscribeQuality, videoSubscribeQuality,
}, },
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
resolution: VideoPresets[key].resolution, resolution: VideoPresets[key].resolution,
deviceId: { exact: videoDeviceId }, deviceId: { exact: videoDeviceId },
processor: processor:
BackgroundProcessorFactory.deserializeProcessor(processorSerialized), BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
}) })
} }
} }
+31 -9
View File
@@ -273,17 +273,39 @@
"title": "Virtueller Hintergrund", "title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:", "selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:", "apply": "Ersetze deinen Hintergrund:",
"descriptions": { "personal": {
"0": "Gerilltes Holzmöbel", "title": "Meine Hintergründe",
"1": "Besprechungsraum", "selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
"2": "Loft mit schwarzer Glaswand", "notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
"3": "Esszimmer", "warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
"4": "Holzregale", "uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
"5": "Holztreppe", "errors": {
"6": "Graue Bibliothek", "close": "Schließen",
"7": "Kaffeetheke" "file_too_large": {
"title": "Datei zu groß",
"description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ungültiger Dateityp",
"description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei."
}
}
},
"presets": {
"title": "Vorschläge",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
} }
}, },
"faceLandmarks": { "faceLandmarks": {
"title": "Visuelle Effekte", "title": "Visuelle Effekte",
"glasses": { "glasses": {
+31 -10
View File
@@ -270,18 +270,39 @@
} }
}, },
"virtual": { "virtual": {
"title": "Virtual background", "title": "Virtual backgrounds",
"selectedLabel": "Background applied:", "selectedLabel": "Background applied:",
"apply": "Replace your background:", "apply": "Replace your background:",
"descriptions": { "personal": {
"0": "Fluted wooden furniture", "title": "My backgrounds",
"1": "Meeting room", "selectFileTooltip": "Select an image file to use as a personal background",
"2": "Loft with black glass partition", "notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
"3": "Dining room", "warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
"4": "Wooden shelves", "uploadLimitReached": "You cannot upload more personal backgrounds.",
"5": "Wooden staircase", "errors": {
"6": "Gray library", "close": "Close",
"7": "Coffee counter" "file_too_large": {
"title": "File too large",
"description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Invalid file type",
"description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
} }
}, },
"faceLandmarks": { "faceLandmarks": {
+31 -10
View File
@@ -270,18 +270,39 @@
} }
}, },
"virtual": { "virtual": {
"title": "Arrière-plan virtuel", "title": "Arrière-plans virtuels",
"selectedLabel": "Arrière-plan appliqué :", "selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :", "apply": "Remplacer votre arrière plan :",
"descriptions": { "personal": {
"0": "Meuble cannelé en bois", "title": "Mes arrière-plans",
"1": "Salle de réunion", "selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
"2": "Loft avec verrière noire", "notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
"3": "Salle à manger", "warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
"4": "Étagères en bois", "uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
"5": "Escalier en bois", "errors": {
"6": "Bibliothèque grise", "close": "Fermer",
"7": "Comptoir de café" "file_too_large": {
"title": "Fichier trop volumineux",
"description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo."
},
"invalid_file_type": {
"title": "Type de fichier non valide",
"description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}."
}
}
},
"presets": {
"title": "Suggestions",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
} }
}, },
"faceLandmarks": { "faceLandmarks": {
+30 -9
View File
@@ -273,15 +273,36 @@
"title": "Virtuele achtergrond", "title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:", "selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:", "apply": "Vervang je achtergrond:",
"descriptions": { "personal": {
"0": "Geprofileerd houten meubel", "title": "Mijn achtergronden",
"1": "Vergaderruimte", "selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
"2": "Loft met zwarte glaswand", "notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
"3": "Eetkamer", "warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
"4": "Houten planken", "uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
"5": "Houten trap", "errors": {
"6": "Grijze bibliotheek", "close": "Sluiten",
"7": "Koffiebar" "file_too_large": {
"title": "Bestand te groot",
"description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Ongeldig bestandstype",
"description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand."
}
}
},
"presets": {
"title": "Suggesties",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
} }
}, },
"faceLandmarks": { "faceLandmarks": {
+4
View File
@@ -36,6 +36,10 @@ export const text = cva({
textStyle: 'body', textStyle: 'body',
fontWeight: 'bold', fontWeight: 'bold',
}, },
bodyXsMedium: {
textStyle: 'body',
fontWeight: 'medium',
},
body: { body: {
textStyle: 'body', textStyle: 'body',
}, },
+14 -4
View File
@@ -1,16 +1,19 @@
import { proxy, subscribe } from 'valtio' import { proxy, subscribe } from 'valtio'
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur' import {
ProcessorConfig,
ProcessorType,
} from '@/features/rooms/livekit/components/blur'
import { import {
loadUserChoices, loadUserChoices,
saveUserChoices,
LocalUserChoices as LocalUserChoicesLK, LocalUserChoices as LocalUserChoicesLK,
saveUserChoices,
} from '@livekit/components-core' } from '@livekit/components-core'
import { VideoQuality } from 'livekit-client' import { VideoQuality } from 'livekit-client'
export type VideoResolution = 'h720' | 'h360' | 'h180' export type VideoResolution = 'h720' | 'h360' | 'h180'
export type LocalUserChoices = LocalUserChoicesLK & { export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized processorConfig?: ProcessorConfig
noiseReductionEnabled?: boolean noiseReductionEnabled?: boolean
audioOutputDeviceId?: string audioOutputDeviceId?: string
videoPublishResolution?: VideoResolution videoPublishResolution?: VideoResolution
@@ -28,7 +31,14 @@ function getUserChoicesState(): LocalUserChoices {
} }
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState()) export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
subscribe(userChoicesStore, () => { subscribe(userChoicesStore, () => {
saveUserChoices(userChoicesStore, false) saveUserChoices(userChoicesStore, false)
}) })
if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) {
// this happens when a not authenticated user had changed their background image
// we restore their last processor config to avoid displaying a black screen.
userChoicesStore.processorConfig = undefined
}
}
+11
View File
@@ -95,3 +95,14 @@ html:has(.lk-video-conference) {
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD; U+2212, U+2215, U+FEFF, U+FFFD;
} }
.hoverGroup .hoverGroupChild {
opacity: 0;
pointer-events: none;
}
.hoverGroup:hover .hoverGroupChild,
.hoverGroup:focus-within .hoverGroupChild {
opacity: 1;
pointer-events: auto;
}
+8
View File
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
port: parseInt(env.VITE_PORT) || 3000, port: parseInt(env.VITE_PORT) || 3000,
host: env.VITE_HOST ?? 'localhost', host: env.VITE_HOST ?? 'localhost',
allowedHosts: ['.nip.io'], allowedHosts: ['.nip.io'],
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
proxy: {
'/media': {
target: 'http://localhost:8083',
changeOrigin: true,
secure: false
}
}
}, },
} }
}) })