mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-24 08:56:28 +00:00
wip handle virtual background loading in an iframe context
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessTokenStore } from '@/stores/accessToken'
|
||||||
|
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive companion of resolveMediaUrl for browser-native consumers
|
||||||
|
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
|
||||||
|
* returns a stable lookup, identity in regular mode.
|
||||||
|
*
|
||||||
|
* Object URLs come from the shared session-lifetime cache and are never
|
||||||
|
* revoked here: they may be used concurrently by the background
|
||||||
|
* processors.
|
||||||
|
*/
|
||||||
|
export const useResolvedMediaUrls = (
|
||||||
|
urls: (string | null | undefined)[]
|
||||||
|
): ((url: string) => string) => {
|
||||||
|
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||||
|
const { accessToken } = useSnapshot(accessTokenStore)
|
||||||
|
|
||||||
|
// Stable dependency for the effect, insensitive to array identity
|
||||||
|
const urlsKey = urls.filter(Boolean).sort().join('\n')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken || !urlsKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const resolveAll = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
urlsKey.split('\n').map(async (url) => {
|
||||||
|
try {
|
||||||
|
return [url, await resolveMediaUrl(url)] as const
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return [url, url] as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
if (isMounted) {
|
||||||
|
setResolved(Object.fromEntries(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveAll()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [accessToken, urlsKey])
|
||||||
|
|
||||||
|
// Stable identity so that consumers can safely list the resolver in
|
||||||
|
// their memo dependencies: it only changes when resolutions land.
|
||||||
|
return useCallback((url: string) => resolved[url] ?? url, [resolved])
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
|
// Session-lifetime cache: object URLs are shared between every consumer
|
||||||
|
// of a given media (background processors, thumbnails) and are therefore
|
||||||
|
// never revoked - their number is bounded by the user's custom
|
||||||
|
// backgrounds, and they die with the page like the access token does.
|
||||||
|
const objectUrlCache = new Map<string, string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an authenticated /media/ URL for the embedded (token) mode.
|
||||||
|
*
|
||||||
|
* Media files are served behind an nginx auth_request subrequest that
|
||||||
|
* authenticates the original request. In regular mode the session cookie
|
||||||
|
* rides along browser-native loads (img.src, CSS url()) and the URL is
|
||||||
|
* returned unchanged, without any fetch. In embedded mode the
|
||||||
|
* third-party cookie is blocked and native loads cannot carry the
|
||||||
|
* Authorization header, so the media is fetched here with the Bearer
|
||||||
|
* header - which the media-auth endpoint accepts, as it sits behind the
|
||||||
|
* default authentication stack - and exposed as a blob object URL.
|
||||||
|
*/
|
||||||
|
export const resolveMediaUrl = async (url: string): Promise<string> => {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = objectUrlCache.get(url)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to resolve media url ${url}: HTTP ${response.status}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob())
|
||||||
|
objectUrlCache.set(url, objectUrl)
|
||||||
|
|
||||||
|
return objectUrl
|
||||||
|
}
|
||||||
+9
-4
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.sourceSettings = this.source!.getSettings()
|
this.sourceSettings = this.source!.getSettings()
|
||||||
this.videoElement = opts.element as HTMLVideoElement
|
this.videoElement = opts.element as HTMLVideoElement
|
||||||
|
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
this._createMainCanvas()
|
this._createMainCanvas()
|
||||||
this._createMaskCanvas()
|
this._createMaskCanvas()
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
posthog.capture('firefox-blurring-init')
|
posthog.capture('firefox-blurring-init')
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
async _initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Virtual background is only supported for virtual background'
|
'Virtual background is only supported for virtual background'
|
||||||
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||||
if (this.options.imagePath || needsUpdate) {
|
if (this.options.imagePath || needsUpdate) {
|
||||||
|
// Embedded (token) mode: img.src cannot carry the Authorization
|
||||||
|
// header, resolve the media to a blob object URL first. Identity
|
||||||
|
// in regular mode.
|
||||||
|
const imagePath = await resolveMediaUrl(this.options.imagePath!)
|
||||||
this.virtualBackgroundImage = document.createElement('img')
|
this.virtualBackgroundImage = document.createElement('img')
|
||||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
this.virtualBackgroundImage.src = imagePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
this._initVirtualBackgroundImage()
|
await this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
+14
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||||
import {
|
import {
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
BackgroundProcessor,
|
BackgroundProcessor,
|
||||||
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||||
return this.processor.init(opts)
|
await this.processor.init(opts)
|
||||||
|
// Embedded (token) mode: the constructor passed the raw imagePath,
|
||||||
|
// whose native load cannot carry the Authorization header. Swap it
|
||||||
|
// for a resolved blob object URL. No-op in regular mode.
|
||||||
|
if (this.opts.type === 'virtual') {
|
||||||
|
const imagePath = await resolveMediaUrl(this.opts.imagePath)
|
||||||
|
if (imagePath !== this.opts.imagePath) {
|
||||||
|
await this.processor.updateTransformerOptions({ imagePath })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||||
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: ProcessorConfig): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
|
if (opts.type === 'virtual') {
|
||||||
|
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||||
|
}
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
const newProcessorType =
|
const newProcessorType =
|
||||||
|
|||||||
+10
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '../blur'
|
} from '../blur'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useResolvedMediaUrls } from '@/features/files/hooks/useResolvedMediaUrls'
|
||||||
import { Button, Dialog, 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 { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
@@ -277,6 +278,14 @@ export const EffectsConfiguration = ({
|
|||||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||||
false
|
false
|
||||||
|
|
||||||
|
// Thumbnails are browser-native loads (CSS url()) which cannot carry
|
||||||
|
// the Authorization header in embedded (token) mode: resolve them. The
|
||||||
|
// processor configs keep the stable raw URLs - they are persisted in
|
||||||
|
// the user choices - and the processors resolve them internally.
|
||||||
|
const resolveMediaUrl = useResolvedMediaUrls(
|
||||||
|
(filesQ.data?.results ?? []).map((file) => file.url)
|
||||||
|
)
|
||||||
|
|
||||||
const getHandleSelectChangeFile = useCallback(
|
const getHandleSelectChangeFile = useCallback(
|
||||||
(file: ApiFileItem) => {
|
(file: ApiFileItem) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
@@ -754,7 +763,7 @@ export const EffectsConfiguration = ({
|
|||||||
bgSize: 'cover',
|
bgSize: 'cover',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${option.file.url!})`,
|
backgroundImage: `url(${resolveMediaUrl(option.file.url!)})`,
|
||||||
}}
|
}}
|
||||||
data-attr={`toggle-virtual-${option.file.id}`}
|
data-attr={`toggle-virtual-${option.file.id}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { proxy, subscribe } from 'valtio'
|
import { proxy, subscribe } from 'valtio'
|
||||||
|
import { initializeAccessTokenFromFragment } from '@/features/auth/api/exchangeAccessToken'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
import {
|
import {
|
||||||
ProcessorConfig,
|
ProcessorConfig,
|
||||||
ProcessorType,
|
ProcessorType,
|
||||||
@@ -48,10 +50,19 @@ if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
|||||||
// we restore clear the processor config to avoid displaying a black screen.
|
// we restore clear the processor config to avoid displaying a black screen.
|
||||||
userChoicesStore.processorConfig = undefined
|
userChoicesStore.processorConfig = undefined
|
||||||
} else if (userChoicesStore.processorConfig.fileId) {
|
} else if (userChoicesStore.processorConfig.fileId) {
|
||||||
|
// Embedded (token) mode: this module loads before the transit code
|
||||||
|
// exchange has settled - wait for it, and carry the Bearer header,
|
||||||
|
// otherwise the check below would wrongly clear the config.
|
||||||
|
await initializeAccessTokenFromFragment()
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
|
||||||
// Checking if the image is still available / accessible
|
// Checking if the image is still available / accessible
|
||||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||||
// We bypass the cache to ensure we have access
|
// We bypass the cache to ensure we have access
|
||||||
cache: 'reload',
|
cache: 'reload',
|
||||||
|
...(accessToken && {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// if we cannot fetch the image (likely a 401 from the backend because
|
// if we cannot fetch the image (likely a 401 from the backend because
|
||||||
|
|||||||
Reference in New Issue
Block a user