From 376168c922e41908210f037c9887adbceaa1649d Mon Sep 17 00:00:00 2001 From: Hampus Date: Wed, 2 Sep 2026 17:31:04 +0200 Subject: [PATCH] feat(voice): add the camera background effects pipeline (#2378) --- biome.json | 2 +- fluxer_app/package.json | 3 + fluxer_app/rspack.config.mjs | 7 + .../VoiceEngineV2AppMediaExecutionAdapter.tsx | 12 +- .../v2/VoiceEngineV2AppPermissionAdapter.ts | 11 +- .../voice/utils/CameraVideoProcessor.ts | 172 +++- .../voice/utils/VideoBackgroundProcessor.ts | 142 ++- .../camera-effects/AggregateOperations.ts | 65 ++ .../utils/camera-effects/BoundedResponse.ts | 180 ++++ .../camera-effects/CameraCaptureContract.ts | 21 + .../utils/camera-effects/ErrorDiagnostic.ts | 148 +++ .../MediaStreamTrackProcessorPolyfill.ts | 172 ++++ .../WebCameraBackgroundSupport.ts | 90 ++ .../WebCameraEffectCanvasRenderer.ts | 297 ++++++ .../WebCameraEffectCustomFrameSource.ts | 726 +++++++++++++ .../WebCameraEffectCustomImage.ts | 126 +++ .../camera-effects/WebCameraEffectMask.ts | 23 + .../camera-effects/WebCameraEffectPipeline.ts | 940 +++++++++++++++++ .../camera-effects/WebCameraEffectProtocol.ts | 327 ++++++ .../camera-effects/WebCameraEffectRenderer.ts | 14 + .../WebCameraEffectSegmentationOwner.ts | 130 +++ .../WebCameraEffectVideoFrameSource.ts | 428 ++++++++ .../WebCameraEffectWebGLMaskRefiner.ts | 265 +++++ .../WebCameraEffectWebGPUConstants.ts | 13 + .../WebCameraEffectWebGPUPipelines.ts | 180 ++++ .../WebCameraEffectWebGPURenderer.ts | 973 ++++++++++++++++++ .../WebCameraEffectWebGPUShaders.ts | 249 +++++ .../camera-effects/WebCameraEffectWorker.ts | 611 +++++++++++ .../camera-effects/WebSelfieSegmenter.ts | 359 +++++++ .../utils/camera-effects/models/NOTICE.txt | 13 + .../selfie_segmentation_256x256.d.onnx.ts | 4 + .../models/selfie_segmentation_256x256.onnx | Bin 0 -> 462352 bytes fluxer_app/src/types/assets.d.ts | 5 + fluxer_app/src/types/ort-wasm-asset.d.ts | 4 + fluxer_app/src/types/rspack-runtime.d.ts | 1 + fluxer_app/src/types/webgpu.d.ts | 2 + fluxer_app/tsconfig.json | 1 + pnpm-lock.yaml | 120 +++ pnpm-workspace.yaml | 3 + 39 files changed, 6802 insertions(+), 37 deletions(-) create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/AggregateOperations.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/BoundedResponse.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/CameraCaptureContract.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/ErrorDiagnostic.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraBackgroundSupport.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCanvasRenderer.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomImage.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectMask.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectPipeline.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectProtocol.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectRenderer.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectSegmentationOwner.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectVideoFrameSource.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGLMaskRefiner.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUPipelines.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPURenderer.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUShaders.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWorker.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/WebSelfieSegmenter.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/models/NOTICE.txt create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.d.onnx.ts create mode 100644 fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.onnx create mode 100644 fluxer_app/src/types/ort-wasm-asset.d.ts create mode 100644 fluxer_app/src/types/rspack-runtime.d.ts create mode 100644 fluxer_app/src/types/webgpu.d.ts diff --git a/biome.json b/biome.json index ca4905183..ce27b5b27 100644 --- a/biome.json +++ b/biome.json @@ -20,7 +20,7 @@ "bracketSpacing": false, "bracketSameLine": false }, - "globals": ["React"] + "globals": ["React", "__webpack_base_uri__"] }, "json": { "formatter": { diff --git a/fluxer_app/package.json b/fluxer_app/package.json index cbb5b8be5..b50202ecb 100644 --- a/fluxer_app/package.json +++ b/fluxer_app/package.json @@ -229,6 +229,8 @@ "mobx-persist-store": "catalog:", "mobx-react-lite": "catalog:", "motion": "catalog:", + "onnxruntime-common": "catalog:", + "onnxruntime-web": "catalog:", "qrcode": "catalog:", "react": "catalog:", "react-aria-components": "catalog:", @@ -265,6 +267,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@typescript/native-preview": "catalog:", + "@webgpu/types": "catalog:", "autoprefixer": "catalog:", "esbuild": "catalog:", "happy-dom": "20.12.0", diff --git a/fluxer_app/rspack.config.mjs b/fluxer_app/rspack.config.mjs index 09fe4c7f9..9ba8f0e79 100644 --- a/fluxer_app/rspack.config.mjs +++ b/fluxer_app/rspack.config.mjs @@ -377,6 +377,13 @@ export default () => { ...(workerWasmPublicPath ? {publicPath: workerWasmPublicPath} : {}), }, }, + { + test: /\.onnx$/, + type: 'asset/resource', + generator: { + filename: isProduction ? 'assets/[contenthash:16][ext]' : 'assets/[name].[hash][ext]', + }, + }, { test: /\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|mp3|wav|ogg|mp4|webm)$/, type: 'asset/resource', diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx index 6edbd99e9..6b3b34cbc 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppMediaExecutionAdapter.tsx @@ -48,6 +48,7 @@ import { import type {VoiceStateSyncPartial} from '@app/features/voice/engine/VoiceStateSyncTypes'; import { enforceLocalMediaPublicationCap, + getLocalCameraPublications, getLocalMicrophonePublications, getPrimaryLocalMicrophonePublication, } from '@app/features/voice/engine/VoiceTrackPublicationUtils'; @@ -97,7 +98,7 @@ import LocalVoiceState from '@app/features/voice/state/LocalVoiceState'; import ParticipantVolume from '@app/features/voice/state/ParticipantVolume'; import VoiceSettings from '@app/features/voice/state/VoiceSettings'; import {buildMicrophonePublishOptions} from '@app/features/voice/utils/AudioPublishOptions'; -import {applyBackgroundProcessor} from '@app/features/voice/utils/VideoBackgroundProcessor'; +import {applyBackgroundProcessor, clearCameraVideoProcessor} from '@app/features/voice/utils/VideoBackgroundProcessor'; import { removeVoiceInputProcessor, syncVoiceInputProcessor, @@ -1133,6 +1134,13 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store { assert.ok(participant, 'camera transition requires a local participant'); assertBoolean(enabled, 'publishCameraTransition.enabled'); await this.enforceCameraPublicationCap(participant, enabled ? 'before camera enable' : 'before camera disable'); + if (!enabled) { + this.unbindCameraLifecycle(); + const cameraTrack = getLocalCameraPublications(participant)[0]?.track as LocalVideoTrack | undefined; + if (cameraTrack != null) { + await clearCameraVideoProcessor(cameraTrack); + } + } const videoResolution = getCameraVideoPreset(VoiceSettings.getCameraResolution()); await participant.setCameraEnabled(enabled, {resolution: videoResolution, ...restOptions}); await this.enforceCameraPublicationCap(participant, enabled ? 'after camera enable' : 'after camera disable'); @@ -1228,6 +1236,8 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store { if (!cameraTrack) { return; } + this.unbindCameraLifecycle(); + await clearCameraVideoProcessor(cameraTrack as LocalVideoTrack); await participant.unpublishTrack(cameraTrack); await this.publishCameraTransition(activeRoom, true, {deviceId: VoiceSettings.getVideoDeviceId()}); updateLocalParticipantFromRoom(activeRoom); diff --git a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPermissionAdapter.ts b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPermissionAdapter.ts index c55f0296c..23897cecf 100644 --- a/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPermissionAdapter.ts +++ b/fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppPermissionAdapter.ts @@ -15,14 +15,16 @@ import { } from '@app/features/voice/engine/VoicePermissionStateMachine'; import { enforceLocalMediaPublicationCap, + getLocalCameraPublications, getLocalMicrophonePublications, getLocalScreenSharePublications, unpublishLocalMediaPublications, } from '@app/features/voice/engine/VoiceTrackPublicationUtils'; import {asVoiceTrackSource, VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource'; +import {clearCameraVideoProcessor} from '@app/features/voice/utils/VideoBackgroundProcessor'; import {removeVoiceInputProcessor} from '@app/features/voice/utils/VoiceInputProcessor'; import {getVoiceChannelPermissions, type VoiceChannelPermissions} from '@app/features/voice/utils/VoicePermissionUtils'; -import type {LocalAudioTrack, Room} from 'livekit-client'; +import type {LocalAudioTrack, LocalVideoTrack, Room} from 'livekit-client'; export { createVoiceEngineV2AppSystemPermissionAdapter, @@ -325,11 +327,16 @@ class VoiceEngineV2AppPermissionAdapter extends Store { } break; } - case 'video': + case 'video': { await enforceLocalMediaPublicationCap(localParticipant, 'camera'); + const cameraTrack = getLocalCameraPublications(localParticipant)[0]?.track as LocalVideoTrack | undefined; + if (cameraTrack != null) { + await clearCameraVideoProcessor(cameraTrack); + } await localParticipant.setCameraEnabled(false); await enforceLocalMediaPublicationCap(localParticipant, 'camera'); break; + } case 'screenShare': await unpublishLocalMediaPublications(localParticipant, getLocalScreenSharePublications(localParticipant), { stopOnUnpublish: true, diff --git a/fluxer_app/src/features/voice/utils/CameraVideoProcessor.ts b/fluxer_app/src/features/voice/utils/CameraVideoProcessor.ts index 590d704b2..5882fcebb 100644 --- a/fluxer_app/src/features/voice/utils/CameraVideoProcessor.ts +++ b/fluxer_app/src/features/voice/utils/CameraVideoProcessor.ts @@ -1,6 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Logger} from '@app/features/platform/utils/AppLogger'; +import { + WebCameraEffectPipeline, + type WebCameraPipeline, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectPipeline'; +import type {WebCameraEffectConfig} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; import type {Track, TrackProcessor, VideoProcessorOptions} from 'livekit-client'; const logger = new Logger('CameraVideoProcessor'); @@ -32,6 +37,13 @@ interface MediaStreamTrackProcessorGlobals { export interface CameraVideoProcessorOptions { mirror?: boolean; + background?: WebCameraEffectConfig | null; +} + +export interface CameraVideoProcessorHandle extends VideoTrackProcessor { + canUpdate(options: CameraVideoProcessorOptions): boolean; + update(options: CameraVideoProcessorOptions): Promise; + setOperationalFailureHandler(handler: (error: Error) => void): void; } function isTrackProcessorConstructor(value: unknown): value is MediaStreamTrackProcessorConstructor { @@ -370,41 +382,173 @@ class MirrorVideoProcessor implements VideoTrackProcessor { }; } -class CameraVideoProcessor implements VideoTrackProcessor { +class CameraVideoProcessor implements CameraVideoProcessorHandle { name = 'camera-video-processor'; processedTrack?: MediaStreamTrack; private mirrorProcessor: MirrorVideoProcessor | null = null; + private backgroundPipeline: WebCameraPipeline | null = null; + private currentOptions: CameraVideoProcessorOptions; + private operationTail: Promise = Promise.resolve(); + private destroyPromise: Promise | null = null; + private operationalFailureHandler: ((error: Error) => void) | null = null; + private initialized = false; + private destroyed = false; - constructor(private readonly options: CameraVideoProcessorOptions) {} + constructor(options: CameraVideoProcessorOptions) { + this.currentOptions = options; + } async init(opts: VideoProcessorOptions): Promise { - await this.setup(opts); + await this.runExclusive(async () => { + if (this.initialized || this.destroyed) { + throw new Error('Camera video processor cannot be initialized in its current lifecycle state'); + } + await this.setup(opts); + this.initialized = true; + }); } async restart(opts: VideoProcessorOptions): Promise { - await this.destroy(); - await this.setup(opts); + await this.runExclusive(async () => { + if (this.destroyed) { + throw new Error('Cannot restart a destroyed camera video processor'); + } + await this.teardown(); + this.initialized = false; + await this.setup(opts); + this.initialized = true; + }); } - async destroy(): Promise { + destroy(): Promise { + if (this.destroyPromise == null) { + this.destroyPromise = this.runExclusive(async () => { + if (this.destroyed) { + return; + } + await this.teardown(); + this.initialized = false; + this.destroyed = true; + }); + } + return this.destroyPromise; + } + + canUpdate(options: CameraVideoProcessorOptions): boolean { + const currentMirror = this.currentOptions.mirror === true; + const nextMirror = options.mirror === true; + if (currentMirror !== nextMirror) { + return false; + } + const currentHasBackground = this.currentOptions.background != null; + const nextHasBackground = options.background != null; + return currentHasBackground === nextHasBackground; + } + + setOperationalFailureHandler(handler: (error: Error) => void): void { + if (this.operationalFailureHandler != null) { + throw new Error('Camera video processor operational failure handler was already assigned'); + } + this.operationalFailureHandler = handler; + } + + async update(options: CameraVideoProcessorOptions): Promise { + await this.runExclusive(async () => { + if (!this.initialized || this.destroyed) { + throw new Error('Cannot update an inactive camera video processor'); + } + if (!this.canUpdate(options)) { + throw new Error('Camera video processor update would change its output topology'); + } + const background = options.background ?? null; + if (background != null) { + const backgroundPipeline = this.backgroundPipeline; + if (backgroundPipeline == null) { + throw new Error('Camera video processor has no active background pipeline to update'); + } + await backgroundPipeline.updateConfig({background}); + } + this.currentOptions = options; + }); + } + + private runExclusive(operation: () => Promise): Promise { + const result = this.operationTail.then(operation); + this.operationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async teardown(): Promise { + const backgroundPipeline = this.backgroundPipeline; + if (backgroundPipeline) { + try { + backgroundPipeline.beginStop(); + } catch (error) { + logger.warn('Failed to signal camera background pipeline stop intent', {error}); + } + } const mirrorProcessor = this.mirrorProcessor; + this.backgroundPipeline = null; this.mirrorProcessor = null; this.processedTrack = undefined; + if (backgroundPipeline) { + try { + backgroundPipeline.stop(); + } catch (error) { + logger.warn('Failed to stop camera background pipeline', {error}); + } + } await mirrorProcessor?.destroy(); } private async setup(opts: VideoProcessorOptions): Promise { - let inputTrack = opts.track; - if (this.options.mirror) { - const mirrorProcessor = new MirrorVideoProcessor(); - this.mirrorProcessor = mirrorProcessor; - await mirrorProcessor.init({...opts, track: inputTrack}); - inputTrack = mirrorProcessor.processedTrack ?? inputTrack; + const background = this.currentOptions.background ?? null; + try { + let inputTrack = opts.track; + if (this.currentOptions.mirror) { + const mirrorProcessor = new MirrorVideoProcessor(); + this.mirrorProcessor = mirrorProcessor; + await mirrorProcessor.init({...opts, track: inputTrack}); + inputTrack = mirrorProcessor.processedTrack ?? inputTrack; + } + if (background) { + const backgroundPipeline = await WebCameraEffectPipeline.create({ + source: inputTrack, + config: {background}, + onFailure: (_pipeline, error) => { + this.handleBackgroundPipelineFailure(error); + }, + }); + this.backgroundPipeline = backgroundPipeline; + this.processedTrack = backgroundPipeline.outputTrack; + return; + } + this.processedTrack = inputTrack === opts.track ? undefined : inputTrack; + } catch (error) { + await this.teardown(); + throw error; } - this.processedTrack = inputTrack === opts.track ? undefined : inputTrack; + } + + private handleBackgroundPipelineFailure(error: unknown): void { + const normalizedError = + error instanceof Error ? error : new Error('Camera background pipeline failed', {cause: error}); + const handler = this.operationalFailureHandler; + if (handler == null) { + logger.error('Camera background pipeline failed without a recovery owner', {error: normalizedError}); + return; + } + handler(normalizedError); } } -export function createCameraVideoProcessor(options: CameraVideoProcessorOptions): VideoTrackProcessor { +export function createCameraVideoProcessor(options: CameraVideoProcessorOptions): CameraVideoProcessorHandle { return new CameraVideoProcessor(options); } + +export function isCameraVideoProcessor(processor: unknown): processor is CameraVideoProcessorHandle { + return processor instanceof CameraVideoProcessor; +} diff --git a/fluxer_app/src/features/voice/utils/VideoBackgroundProcessor.ts b/fluxer_app/src/features/voice/utils/VideoBackgroundProcessor.ts index c1b4fa4ef..6a467adfb 100644 --- a/fluxer_app/src/features/voice/utils/VideoBackgroundProcessor.ts +++ b/fluxer_app/src/features/voice/utils/VideoBackgroundProcessor.ts @@ -1,21 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Logger} from '@app/features/platform/utils/AppLogger'; -import VoiceSettings from '@app/features/voice/state/VoiceSettings'; +import {getBackgroundMediaObjectURL} from '@app/features/theme/utils/BackgroundImageDB'; +import VoiceSettings, { + type BackgroundImage, + BLUR_BACKGROUND_ID, + NONE_BACKGROUND_ID, +} from '@app/features/voice/state/VoiceSettings'; import { + type CameraVideoProcessorHandle, type CameraVideoProcessorOptions, createCameraVideoProcessor, + isCameraVideoProcessor, } from '@app/features/voice/utils/CameraVideoProcessor'; +import {CameraBackgroundMode} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import type {WebCameraEffectConfig} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; +import {areVoiceBackgroundsAvailable} from '@app/features/voice/utils/VoiceBackgroundAvailability'; import type {LocalVideoTrack} from 'livekit-client'; const logger = new Logger('VideoBackgroundProcessor'); export interface BackgroundProcessorOptions { backgroundImageId?: string; - backgroundImages?: Array<{ - id: string; - createdAt: number; - }>; + backgroundImages?: Array; mirrorCamera?: boolean; } @@ -23,24 +30,120 @@ export interface AppliedBackgroundProcessor { destroy: () => Promise; } -async function clearBackgroundProcessor(track: LocalVideoTrack): Promise { +export async function clearCameraVideoProcessor(track: LocalVideoTrack): Promise { if (!track.getProcessor()) { return; } await track.stopProcessor(false); + if (track.getProcessor()) { + throw new Error('Camera video processor remained attached after it was cleared'); + } logger.info('Cleared background processor'); } +async function recoverRawCameraAfterProcessorFailure( + track: LocalVideoTrack, + processor: CameraVideoProcessorHandle, + error: Error, +): Promise { + try { + const recovered = await track.stopProcessorIfCurrent(processor, false); + if (recovered) { + logger.warn('Camera video processor failed; restored the raw camera track', {error}); + } + } catch (recoveryError) { + if (track.mediaStreamTrack.readyState === 'ended') { + logger.warn('Camera video processor failed and the camera track was already gone during recovery', { + error, + recoveryError, + }); + return; + } + logger.error('Camera video processor failed and raw camera recovery was incomplete', { + error, + recoveryError, + }); + } +} + async function applyCameraVideoProcessor( track: LocalVideoTrack, options: CameraVideoProcessorOptions, logLabel: string, -): Promise { - await clearBackgroundProcessor(track); +): Promise { + const updatedProcessor = await track.runWithTrackChangeLock(async () => { + const activeProcessor = track.getProcessor(); + if (!isCameraVideoProcessor(activeProcessor) || !activeProcessor.canUpdate(options)) { + return null; + } + await activeProcessor.update(options); + if (track.getProcessor() !== activeProcessor) { + throw new Error('Updated camera video processor is no longer attached to the active track'); + } + if (!activeProcessor.processedTrack || activeProcessor.processedTrack.readyState !== 'live') { + throw new Error('Updated camera video processor has no live output track'); + } + return activeProcessor; + }); + if (updatedProcessor != null) { + logger.info(logLabel); + return updatedProcessor; + } const processor = createCameraVideoProcessor(options); - await track.setProcessor(processor); - logger.info(logLabel); - return processor; + processor.setOperationalFailureHandler((error) => { + void recoverRawCameraAfterProcessorFailure(track, processor, error); + }); + try { + await track.setProcessor(processor); + if (track.getProcessor() !== processor) { + throw new Error('Camera video processor was not retained by the active track'); + } + if (!processor.processedTrack || processor.processedTrack.readyState !== 'live') { + throw new Error('Camera video processor produced no live output track'); + } + logger.info(logLabel); + return processor; + } catch (error) { + try { + if (track.getProcessor() === processor) { + await track.stopProcessor(false); + } + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Camera video processor apply and cleanup both failed'); + } + throw error; + } +} + +async function resolveBackgroundEffectConfig( + options?: BackgroundProcessorOptions, +): Promise { + const backgroundImageId = options?.backgroundImageId ?? VoiceSettings.getBackgroundImageId(); + if (backgroundImageId === NONE_BACKGROUND_ID) { + return null; + } + if (!areVoiceBackgroundsAvailable()) { + throw new Error('Camera background effects are unavailable'); + } + const blurStrength = VoiceSettings.getBackgroundBlurStrength(); + if (backgroundImageId === BLUR_BACKGROUND_ID) { + return {mode: CameraBackgroundMode.BLUR, blurStrength}; + } + const backgroundImages = options?.backgroundImages ?? VoiceSettings.getBackgroundImages(); + const hasImage = backgroundImages.some((image) => image.id === backgroundImageId); + if (!hasImage) { + throw new Error(`Custom camera background is not present in the saved media list: ${backgroundImageId}`); + } + const customMedia = await getBackgroundMediaObjectURL(backgroundImageId); + if (!customMedia) { + throw new Error(`Custom camera background media could not be resolved: ${backgroundImageId}`); + } + return { + mode: CameraBackgroundMode.CUSTOM, + blurStrength, + customMediaURL: customMedia.url, + customMediaKind: customMedia.mediaKind, + }; } export async function applyCameraMirrorProcessor( @@ -49,14 +152,14 @@ export async function applyCameraMirrorProcessor( ) { try { if (!mirrorCamera) { - await clearBackgroundProcessor(track); + await clearCameraVideoProcessor(track); logger.debug('No camera mirror processor applied'); return null; } return applyCameraVideoProcessor(track, {mirror: true}, 'Applied camera mirror'); } catch (error) { logger.warn('Failed to apply camera mirror processor', error); - return null; + throw error; } } @@ -65,16 +168,17 @@ export async function applyBackgroundProcessor( options?: BackgroundProcessorOptions, ): Promise { try { - const voiceSettings = VoiceSettings; - const mirrorCamera = options?.mirrorCamera ?? voiceSettings.getMirrorCamera(); - if (!mirrorCamera) { - await clearBackgroundProcessor(track); + const mirrorCamera = options?.mirrorCamera ?? VoiceSettings.getMirrorCamera(); + const background = await resolveBackgroundEffectConfig(options); + if (!mirrorCamera && background == null) { + await clearCameraVideoProcessor(track); logger.debug('No camera video processor applied'); return null; } - return applyCameraVideoProcessor(track, {mirror: true}, 'Applied camera mirror'); + const logLabel = background == null ? 'Applied camera mirror' : `Applied camera background (${background.mode})`; + return applyCameraVideoProcessor(track, {mirror: mirrorCamera, background}, logLabel); } catch (error) { logger.warn('Failed to apply camera video processor', error); - return null; + throw error; } } diff --git a/fluxer_app/src/features/voice/utils/camera-effects/AggregateOperations.ts b/fluxer_app/src/features/voice/utils/camera-effects/AggregateOperations.ts new file mode 100644 index 000000000..21f02adbf --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/AggregateOperations.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface ThrowCollectedFailuresRequest { + readonly failures: ReadonlyArray; + readonly message: string; +} + +class MissingFailureReasonError extends Error { + constructor() { + super('Operation failed without an error reason'); + this.name = 'MissingFailureReasonError'; + } +} + +function normalizeFailureReason(failure: unknown): unknown { + if (failure == null) { + return new MissingFailureReasonError(); + } + return failure; +} + +function flattenFailures(failures: ReadonlyArray): Array { + const flattened: Array = []; + for (const failure of failures) { + const normalized = normalizeFailureReason(failure); + if (normalized instanceof AggregateError && Array.isArray(normalized.errors)) { + for (const nested of normalized.errors) { + flattened.push(normalizeFailureReason(nested)); + } + continue; + } + flattened.push(normalized); + } + return flattened; +} + +export async function collectSettledFailures(operations: ReadonlyArray>): Promise> { + const outcomes = await Promise.allSettled(operations); + const failures: Array = []; + for (const outcome of outcomes) { + if (outcome.status === 'rejected') { + failures.push(normalizeFailureReason(outcome.reason)); + } + } + return flattenFailures(failures); +} + +export function throwCollectedFailures( + request: ThrowCollectedFailuresRequest & {readonly failures: readonly [unknown, ...Array]}, +): never; +export function throwCollectedFailures(request: ThrowCollectedFailuresRequest): void; +export function throwCollectedFailures(request: ThrowCollectedFailuresRequest): void { + const flattened = flattenFailures(request.failures); + if (flattened.length === 0) { + return; + } + if (flattened.length === 1) { + const failure = flattened[0]; + if (failure == null) { + throw new MissingFailureReasonError(); + } + throw failure; + } + throw new AggregateError(flattened, request.message); +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/BoundedResponse.ts b/fluxer_app/src/features/voice/utils/camera-effects/BoundedResponse.ts new file mode 100644 index 000000000..1bf8f543f --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/BoundedResponse.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface BoundedResponseBodyOptions { + readonly maximumBytes: number; + readonly maximumChunks: number; + readonly description: string; +} + +export interface ReadBoundedResponseBodyRequest extends BoundedResponseBodyOptions { + readonly response: Response; +} + +export interface ResponseDeadlineOptions { + readonly timeoutMilliseconds: number; + readonly description: string; + readonly signal: AbortSignal | null; + readonly operation: (signal: AbortSignal) => Promise; +} + +export interface CancellableResponseBody { + readonly body?: ReadableStream | null; +} + +export interface CancelResponseBodyAndThrowRequest { + readonly description: string; + readonly error: unknown; + readonly response: CancellableResponseBody; +} + +export const BoundedResponseBodyLimit = Object.freeze({ + BYTES: 'bytes', + CHUNKS: 'chunks', +} as const); + +export type BoundedResponseBodyLimit = (typeof BoundedResponseBodyLimit)[keyof typeof BoundedResponseBodyLimit]; + +export class BoundedResponseBodyLimitError extends Error { + readonly description: string; + readonly limit: BoundedResponseBodyLimit; + readonly maximum: number; + + constructor(description: string, limit: BoundedResponseBodyLimit, maximum: number) { + super(`${description} exceeds ${maximum.toString()} ${limit}`); + this.name = 'BoundedResponseBodyLimitError'; + this.description = description; + this.limit = limit; + this.maximum = maximum; + } +} + +class ResponseDeadlineExceededError extends Error { + constructor(description: string, timeoutMilliseconds: number) { + super(`${description} timed out after ${timeoutMilliseconds.toString()} ms`); + this.name = 'ResponseDeadlineExceededError'; + } +} + +interface BoundedResponseChunks { + readonly chunks: Array; + readonly totalBytes: number; +} + +function responseChunkForStorage(chunk: Uint8Array): Uint8Array { + const copy = new Uint8Array(chunk.byteLength); + copy.set(chunk); + return copy; +} + +async function readBoundedResponseChunks(request: ReadBoundedResponseBodyRequest): Promise { + if (request.response.body == null) { + return {chunks: [], totalBytes: 0}; + } + const reader = request.response.body.getReader(); + const chunks: Array = []; + let totalBytes = 0; + try { + for (;;) { + const result = await reader.read(); + if (result.done) { + break; + } + if (chunks.length >= request.maximumChunks) { + throw new BoundedResponseBodyLimitError( + request.description, + BoundedResponseBodyLimit.CHUNKS, + request.maximumChunks, + ); + } + totalBytes += result.value.byteLength; + if (!Number.isSafeInteger(totalBytes) || totalBytes > request.maximumBytes) { + throw new BoundedResponseBodyLimitError( + request.description, + BoundedResponseBodyLimit.BYTES, + request.maximumBytes, + ); + } + chunks.push(responseChunkForStorage(result.value)); + } + } catch (error) { + const failures: Array = [error]; + try { + await reader.cancel(error); + } catch (cancelError) { + failures.push(cancelError); + } + if (failures.length > 1) { + throw new AggregateError(failures, `${request.description} read and cancellation failed`); + } + throw error; + } finally { + try { + reader.releaseLock(); + } catch {} + } + return {chunks, totalBytes}; +} + +export async function readBoundedResponseBytes(request: ReadBoundedResponseBodyRequest): Promise { + const {chunks, totalBytes} = await readBoundedResponseChunks(request); + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function readBoundedResponseArrayBuffer(request: ReadBoundedResponseBodyRequest): Promise { + const bytes = await readBoundedResponseBytes(request); + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +export async function readBoundedResponseBlob(request: ReadBoundedResponseBodyRequest): Promise { + const {chunks} = await readBoundedResponseChunks(request); + const contentType = request.response.headers.get('content-type') ?? ''; + return new Blob(chunks as Array, {type: contentType}); +} + +export async function cancelResponseBodyAndThrow({ + description, + error, + response, +}: CancelResponseBodyAndThrowRequest): Promise { + if (response.body != null) { + try { + await response.body.cancel(error); + } catch (cancelError) { + throw new AggregateError([error, cancelError], `${description} rejection and cancellation failed`); + } + } + throw error; +} + +export async function runWithResponseDeadline(options: ResponseDeadlineOptions): Promise { + const upstreamSignal = options.signal; + if (upstreamSignal?.aborted) { + throw upstreamSignal.reason ?? new Error(`${options.description} was aborted upstream`); + } + const controller = new AbortController(); + const abortFromUpstream = (): void => { + controller.abort(upstreamSignal?.reason ?? new Error(`${options.description} was aborted upstream`)); + }; + if (upstreamSignal != null) { + upstreamSignal.addEventListener('abort', abortFromUpstream, {once: true}); + } + const timer = setTimeout(() => { + controller.abort(new ResponseDeadlineExceededError(options.description, options.timeoutMilliseconds)); + }, options.timeoutMilliseconds); + try { + return await options.operation(controller.signal); + } finally { + clearTimeout(timer); + if (upstreamSignal != null) { + upstreamSignal.removeEventListener('abort', abortFromUpstream); + } + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/CameraCaptureContract.ts b/fluxer_app/src/features/voice/utils/camera-effects/CameraCaptureContract.ts new file mode 100644 index 000000000..6ddb04e76 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/CameraCaptureContract.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const CameraBackgroundMode = Object.freeze({ + NONE: 'none', + BLUR: 'blur', + CUSTOM: 'custom', +} as const); + +export type CameraBackgroundMode = (typeof CameraBackgroundMode)[keyof typeof CameraBackgroundMode]; + +export const MAX_VIDEO_FRAME_RATE = 60; + +export function clampVideoFrameRate(frameRate: number): number { + if (!Number.isFinite(frameRate)) { + return MAX_VIDEO_FRAME_RATE; + } + if (frameRate <= 0) { + return MAX_VIDEO_FRAME_RATE; + } + return Math.min(Math.floor(frameRate), MAX_VIDEO_FRAME_RATE); +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/ErrorDiagnostic.ts b/fluxer_app/src/features/voice/utils/camera-effects/ErrorDiagnostic.ts new file mode 100644 index 000000000..52a9e18d2 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/ErrorDiagnostic.ts @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const ErrorDiagnosticType = Object.freeze({ + AGGREGATE_ERROR: 'aggregate-error', + DOM_EXCEPTION: 'dom-exception', + ERROR: 'error', + NON_ERROR: 'non-error', +} as const); + +export type ErrorDiagnosticType = (typeof ErrorDiagnosticType)[keyof typeof ErrorDiagnosticType]; + +export interface ErrorDiagnostic { + readonly errorType: ErrorDiagnosticType; + readonly message: string; + readonly stack: string | null; +} + +const ERROR_DIAGNOSTIC_ENTRY_MAX = 16; +export const ERROR_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 16_384; +export const ERROR_DIAGNOSTIC_STACK_MAX_LENGTH = 65_536; +const ERROR_DIAGNOSTIC_NAME_MAX_LENGTH = 128; +const ERROR_DIAGNOSTIC_MESSAGE_ENTRY_MAX_LENGTH = 896; +const ERROR_DIAGNOSTIC_STACK_ENTRY_MAX_LENGTH = 4_096; + +export function isErrorDiagnosticType(value: unknown): value is ErrorDiagnosticType { + if (value === ErrorDiagnosticType.AGGREGATE_ERROR) { + return true; + } + if (value === ErrorDiagnosticType.DOM_EXCEPTION) { + return true; + } + if (value === ErrorDiagnosticType.ERROR) { + return true; + } + return value === ErrorDiagnosticType.NON_ERROR; +} + +export function isErrorDiagnostic(value: object): value is ErrorDiagnostic { + const errorType = Reflect.get(value, 'errorType'); + if (!isErrorDiagnosticType(errorType)) { + return false; + } + const message = Reflect.get(value, 'message'); + if (typeof message !== 'string') { + return false; + } + if (message.length > ERROR_DIAGNOSTIC_MESSAGE_MAX_LENGTH) { + return false; + } + const stack = Reflect.get(value, 'stack'); + if (stack === null) { + return true; + } + if (typeof stack !== 'string') { + return false; + } + return stack.length <= ERROR_DIAGNOSTIC_STACK_MAX_LENGTH; +} + +export function getErrorDiagnosticType(error: unknown): ErrorDiagnosticType { + if (error instanceof AggregateError) { + return ErrorDiagnosticType.AGGREGATE_ERROR; + } + if (error instanceof DOMException) { + return ErrorDiagnosticType.DOM_EXCEPTION; + } + if (error instanceof Error) { + return ErrorDiagnosticType.ERROR; + } + return ErrorDiagnosticType.NON_ERROR; +} + +function limitDiagnosticText(value: string, maximumLength: number): string { + if (value.length <= maximumLength) { + return value; + } + return value.slice(0, maximumLength); +} + +function describeError(error: unknown): string { + if (error instanceof Error) { + const name = limitDiagnosticText(error.name, ERROR_DIAGNOSTIC_NAME_MAX_LENGTH); + const message = limitDiagnosticText(error.message, ERROR_DIAGNOSTIC_MESSAGE_ENTRY_MAX_LENGTH); + return `${name}: ${message}`; + } + try { + return limitDiagnosticText(String(error), ERROR_DIAGNOSTIC_MESSAGE_ENTRY_MAX_LENGTH); + } catch { + return 'Unprintable non-error failure'; + } +} + +function collectDiagnosticErrors(error: unknown): ReadonlyArray { + const collected: Array = [error]; + let index = 0; + while (index < collected.length) { + if (collected.length >= ERROR_DIAGNOSTIC_ENTRY_MAX) { + break; + } + const current = collected[index]; + index += 1; + if (current instanceof Error) { + const cause = current.cause; + if (cause !== undefined) { + collected.push(cause); + } + } + if (!(current instanceof AggregateError)) { + continue; + } + if (!Array.isArray(current.errors)) { + continue; + } + for (const nested of current.errors) { + if (collected.length >= ERROR_DIAGNOSTIC_ENTRY_MAX) { + break; + } + collected.push(nested); + } + } + return collected; +} + +function hasErrorStack(error: unknown): error is Error { + if (!(error instanceof Error)) { + return false; + } + return typeof error.stack === 'string'; +} + +export function getErrorDiagnostic(error: unknown): ErrorDiagnostic { + const errors = collectDiagnosticErrors(error); + const message = errors + .map((current, index) => (index === 0 ? describeError(current) : `Cause ${index}: ${describeError(current)}`)) + .join('\n'); + const stack = errors + .filter(hasErrorStack) + .map((current, index) => { + const entry = limitDiagnosticText(current.stack ?? '', ERROR_DIAGNOSTIC_STACK_ENTRY_MAX_LENGTH); + return index === 0 ? entry : `Caused by:\n${entry}`; + }) + .join('\n'); + return { + errorType: getErrorDiagnosticType(error), + message: limitDiagnosticText(message, ERROR_DIAGNOSTIC_MESSAGE_MAX_LENGTH), + stack: stack.length === 0 ? null : limitDiagnosticText(stack, ERROR_DIAGNOSTIC_STACK_MAX_LENGTH), + }; +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill.ts b/fluxer_app/src/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill.ts new file mode 100644 index 000000000..6d8cacb92 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill.ts @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Logger} from '@app/features/platform/utils/AppLogger'; + +export interface TrackProcessorReadable { + readonly readable: ReadableStream; +} + +type MediaStreamTrackProcessorInstance = { + readonly readable: ReadableStream; +}; +type MediaStreamTrackProcessorConstructor = new (options: { + track: MediaStreamTrack; +}) => MediaStreamTrackProcessorInstance; + +const logger = new Logger('MediaStreamTrackProcessorPolyfill'); + +export function mediaStreamTrackProcessorSupported(): boolean { + return 'MediaStreamTrackProcessor' in globalThis; +} + +export function videoFrameCaptureSupported(): boolean { + if (!('VideoFrame' in globalThis)) { + return false; + } + if (!('HTMLVideoElement' in globalThis)) { + return false; + } + return typeof HTMLVideoElement.prototype.requestVideoFrameCallback === 'function'; +} + +function getMediaStreamTrackProcessorConstructor(): MediaStreamTrackProcessorConstructor | null { + const candidate: unknown = Reflect.get(globalThis, 'MediaStreamTrackProcessor'); + if (typeof candidate !== 'function') { + return null; + } + return candidate as MediaStreamTrackProcessorConstructor; +} + +export function createTrackProcessor(track: MediaStreamTrack): TrackProcessorReadable { + const Processor = getMediaStreamTrackProcessorConstructor(); + if (Processor != null) { + return new Processor({track}) as unknown as TrackProcessorReadable; + } + return {readable: createPolyfilledVideoReadable(track) as ReadableStream}; +} + +function createPolyfilledVideoReadable(track: MediaStreamTrack): ReadableStream { + return new PolyfilledVideoTrackProcessor(track).createReadable(); +} + +class PolyfilledVideoTrackProcessor { + private readonly video: HTMLVideoElement; + private stopped = false; + private callbackHandle: number | null = null; + private streamController: ReadableStreamDefaultController | null = null; + + constructor(private readonly track: MediaStreamTrack) { + this.video = document.createElement('video'); + this.video.muted = true; + this.video.autoplay = true; + this.video.playsInline = true; + this.video.srcObject = new MediaStream([track]); + } + + createReadable(): ReadableStream { + return new ReadableStream({ + start: (controller): void => { + this.start(controller); + }, + cancel: (): void => { + this.stop(); + }, + }); + } + + private readonly handleTrackEnded = (): void => { + try { + this.streamController?.close(); + } catch (error) { + logger.warn('Polyfilled video track processor close failed', {error}); + } + this.stop(); + }; + + private stop(): void { + if (this.stopped) { + return; + } + this.stopped = true; + if (this.callbackHandle != null) { + try { + this.video.cancelVideoFrameCallback(this.callbackHandle); + } catch {} + this.callbackHandle = null; + } + try { + this.track.removeEventListener('ended', this.handleTrackEnded); + } catch {} + try { + this.video.srcObject = null; + } catch {} + } + + private reportFailure(error: unknown): void { + try { + this.streamController?.error(error); + } catch {} + this.stop(); + } + + private start(controller: ReadableStreamDefaultController): void { + this.streamController = controller; + this.track.addEventListener('ended', this.handleTrackEnded, {once: true}); + this.video + .play() + .then(() => { + this.scheduleNextFrame(); + }) + .catch((error) => { + if (this.stopped) { + return; + } + this.reportFailure(error); + }); + } + + private scheduleNextFrame(): void { + if (this.stopped) { + return; + } + this.callbackHandle = this.video.requestVideoFrameCallback(this.handleFrame); + } + + private readonly handleFrame: VideoFrameRequestCallback = (_callbackTimeMs, metadata): void => { + if (this.stopped) { + return; + } + if (this.track.readyState === 'ended') { + this.handleTrackEnded(); + return; + } + const controller = this.streamController; + if (controller == null) { + this.reportFailure(new Error('Polyfilled video capture started without a stream controller')); + return; + } + if (controller.desiredSize != null && controller.desiredSize <= 0) { + this.scheduleNextFrame(); + return; + } + this.captureFrame(controller, metadata); + }; + + private captureFrame( + controller: ReadableStreamDefaultController, + metadata: VideoFrameCallbackMetadata, + ): void { + const timestamp = Math.round(metadata.mediaTime * 1_000_000); + let frame: VideoFrame | null = null; + try { + frame = new VideoFrame(this.video, {timestamp}); + controller.enqueue(frame); + frame = null; + } catch (error) { + frame?.close(); + this.reportFailure(error); + return; + } + this.scheduleNextFrame(); + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraBackgroundSupport.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraBackgroundSupport.ts new file mode 100644 index 000000000..290f1ea07 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraBackgroundSupport.ts @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + mediaStreamTrackProcessorSupported, + videoFrameCaptureSupported, +} from '@app/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill'; + +export interface WebCameraSegmentationRuntimeCapability { + readonly available: boolean; + readonly reason: string; +} + +class WebCameraSegmentationCapabilityOwner { + private capability: WebCameraSegmentationRuntimeCapability | null = null; + + get(): WebCameraSegmentationRuntimeCapability { + if (this.capability == null) { + this.capability = detectWebCameraSegmentationCapability(); + } + return this.capability; + } +} + +const webCameraSegmentationCapabilityOwner = new WebCameraSegmentationCapabilityOwner(); + +function transferableReadableStreamSupported(): boolean { + if (typeof structuredClone !== 'function' || !('ReadableStream' in globalThis)) { + return false; + } + const stream = new ReadableStream({ + start(controller): void { + controller.close(); + }, + }); + try { + const transferred = structuredClone(stream, {transfer: [stream]}); + return transferred instanceof ReadableStream; + } catch { + return false; + } +} + +function offscreenCanvasFallbackSupported(): boolean { + if (!('OffscreenCanvas' in globalThis)) { + return false; + } + try { + return new OffscreenCanvas(1, 1).getContext('2d') != null; + } catch { + return false; + } +} + +export function detectWebCameraSegmentationCapability(): WebCameraSegmentationRuntimeCapability { + if (!('window' in globalThis)) { + return {available: false, reason: 'Camera effects require a window and document'}; + } + if (!('document' in globalThis)) { + return {available: false, reason: 'Camera effects require a window and document'}; + } + if (!('Worker' in globalThis)) { + return {available: false, reason: 'Camera effects require module workers and VideoFrame'}; + } + if (!('VideoFrame' in globalThis)) { + return {available: false, reason: 'Camera effects require module workers and VideoFrame'}; + } + if (!('HTMLCanvasElement' in globalThis)) { + return {available: false, reason: 'Camera effects require transferable captured canvas output'}; + } + if (typeof HTMLCanvasElement.prototype.captureStream !== 'function') { + return {available: false, reason: 'Camera effects require transferable captured canvas output'}; + } + if (typeof HTMLCanvasElement.prototype.transferControlToOffscreen !== 'function') { + return {available: false, reason: 'Camera effects require transferable captured canvas output'}; + } + if (!offscreenCanvasFallbackSupported()) { + return {available: false, reason: 'Camera effects require an OffscreenCanvas 2D fallback'}; + } + if (!mediaStreamTrackProcessorSupported() && !videoFrameCaptureSupported()) { + return {available: false, reason: 'Camera effects require bounded VideoFrame capture'}; + } + if (!transferableReadableStreamSupported()) { + return {available: false, reason: 'Camera effects require transferable readable streams'}; + } + return {available: true, reason: 'Worker camera effect pipeline is available'}; +} + +export function webCameraSegmentationRuntimeAvailable(): boolean { + return webCameraSegmentationCapabilityOwner.get().available; +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCanvasRenderer.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCanvasRenderer.ts new file mode 100644 index 000000000..9693b3cdd --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCanvasRenderer.ts @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + collectSettledFailures, + throwCollectedFailures, +} from '@app/features/voice/utils/camera-effects/AggregateOperations'; +import {CameraBackgroundMode} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import type {WebCameraEffectCustomFrameSource} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomImage'; +import { + cameraEffectBlurPixels, + validateCameraEffectFrameDimensions, + WEB_CAMERA_EFFECT_SEGMENTATION_MIN_INTERVAL_MS, + WebCameraEffectBackend, + type WebCameraPipelineConfig, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; +import type {WebCameraEffectRenderer} from '@app/features/voice/utils/camera-effects/WebCameraEffectRenderer'; +import {WebCameraEffectWebGLMaskRefiner} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGLMaskRefiner'; +import {SEG_INPUT_EDGE, WebSelfieSegmenter} from '@app/features/voice/utils/camera-effects/WebSelfieSegmenter'; + +function twoDContext( + canvas: OffscreenCanvas, + options: CanvasRenderingContext2DSettings, +): OffscreenCanvasRenderingContext2D { + const context = canvas.getContext('2d', options); + if (context == null) { + throw new Error('OffscreenCanvas 2D context is unavailable for camera effects'); + } + return context; +} + +function resetContext(context: OffscreenCanvasRenderingContext2D): void { + context.setTransform(1, 0, 0, 1, 0, 0); + context.globalCompositeOperation = 'source-over'; + context.filter = 'none'; + context.globalAlpha = 1; +} + +async function disposeWebSelfieSegmenter(segmenter: WebSelfieSegmenter | null): Promise { + if (segmenter == null) { + return; + } + await segmenter.dispose(); +} + +export class WebCameraEffectCanvasRenderer implements WebCameraEffectRenderer { + private readonly outputCanvas: OffscreenCanvas; + private readonly outputContext: OffscreenCanvasRenderingContext2D; + private readonly segmentationCanvas = new OffscreenCanvas(SEG_INPUT_EDGE, SEG_INPUT_EDGE); + private readonly segmentationContext = twoDContext(this.segmentationCanvas, { + alpha: false, + willReadFrequently: true, + }); + private readonly maskCanvas = new OffscreenCanvas(SEG_INPUT_EDGE, SEG_INPUT_EDGE); + private readonly maskContext = twoDContext(this.maskCanvas, {alpha: true}); + private readonly maskImage = this.maskContext.createImageData(SEG_INPUT_EDGE, SEG_INPUT_EDGE); + private readonly foregroundCanvas = new OffscreenCanvas(1, 1); + private readonly foregroundContext = twoDContext(this.foregroundCanvas, {alpha: true}); + private readonly maskRefiner = WebCameraEffectWebGLMaskRefiner.create(); + private config: WebCameraPipelineConfig = {background: null}; + private segmenter: WebSelfieSegmenter | null = null; + private customFrameSource: WebCameraEffectCustomFrameSource | null = null; + private width = 0; + private height = 0; + private lastSegmentationAt = Number.NEGATIVE_INFINITY; + private maskRevision = 0; + private maskReady = false; + private disposed = false; + + get backend(): WebCameraEffectBackend { + if (this.segmenter == null) { + return WebCameraEffectBackend.CANVAS_WORKER; + } + return WebCameraEffectBackend.WASM_WORKER; + } + + private constructor(canvas: OffscreenCanvas) { + this.outputCanvas = canvas; + this.outputContext = twoDContext(canvas, {alpha: false, desynchronized: true}); + this.outputContext.imageSmoothingEnabled = true; + this.outputContext.imageSmoothingQuality = 'high'; + this.segmentationContext.imageSmoothingEnabled = true; + this.segmentationContext.imageSmoothingQuality = 'medium'; + this.foregroundContext.imageSmoothingEnabled = true; + this.foregroundContext.imageSmoothingQuality = 'high'; + } + + static async create( + canvas: OffscreenCanvas, + config: WebCameraPipelineConfig, + customFrameSource: WebCameraEffectCustomFrameSource | null, + ): Promise { + const renderer = new WebCameraEffectCanvasRenderer(canvas); + try { + await renderer.configure(config, customFrameSource); + await renderer.warmup(); + return renderer; + } catch (error) { + const cleanupFailures = await collectSettledFailures([renderer.dispose()]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'Canvas camera effect initialization failed', + }); + } + } + + async configure( + config: WebCameraPipelineConfig, + customFrameSource: WebCameraEffectCustomFrameSource | null, + ): Promise { + if (this.disposed) { + throw new Error('Cannot configure a disposed camera effect renderer'); + } + const customBackground = config.background?.mode === CameraBackgroundMode.CUSTOM; + if (customBackground !== (customFrameSource != null)) { + throw new Error('Camera effect custom frame source does not match its configuration'); + } + if ( + config.background != null && + config.background.mode === CameraBackgroundMode.BLUR && + typeof this.outputContext.filter !== 'string' + ) { + throw new Error('OffscreenCanvas blur filters are unavailable'); + } + let createdSegmenter: WebSelfieSegmenter | null = null; + try { + if (config.background != null && this.segmenter == null) { + createdSegmenter = await WebSelfieSegmenter.create(); + } + } catch (error) { + const cleanupFailures = await collectSettledFailures([disposeWebSelfieSegmenter(createdSegmenter)]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'Canvas camera effect configuration failed during cleanup', + }); + } + if (this.segmenter == null) { + this.segmenter = createdSegmenter; + } + const backgroundLifecycleChanged = (this.config.background == null) !== (config.background == null); + if (backgroundLifecycleChanged) { + this.segmenter?.reset(); + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskReady = false; + } + this.customFrameSource = customFrameSource; + this.config = config; + } + + async render(frame: VideoFrame, now: number): Promise { + if (this.disposed) { + throw new Error('Cannot render with a disposed camera effect renderer'); + } + const width = frame.displayWidth; + const height = frame.displayHeight; + this.ensureSize(width, height); + const source = frame; + const background = this.config.background; + resetContext(this.outputContext); + if (background == null) { + this.outputContext.globalCompositeOperation = 'copy'; + this.outputContext.drawImage(source, 0, 0, width, height); + this.outputContext.globalCompositeOperation = 'source-over'; + return; + } + await this.maybeSegment(source, now); + this.drawBackground(source, now); + if (!this.maskReady) { + return; + } + resetContext(this.foregroundContext); + this.foregroundContext.globalCompositeOperation = 'copy'; + this.foregroundContext.drawImage(source, 0, 0, width, height); + this.foregroundContext.globalCompositeOperation = 'destination-in'; + const refinedMask = this.maskRefiner?.refine(frame, this.maskCanvas, this.maskRevision, width, height) ?? null; + if (refinedMask == null) { + this.foregroundContext.drawImage(this.maskCanvas, 0, 0, SEG_INPUT_EDGE, SEG_INPUT_EDGE, 0, 0, width, height); + } else { + this.foregroundContext.drawImage(refinedMask, 0, 0, width, height); + } + this.foregroundContext.globalCompositeOperation = 'source-over'; + this.outputContext.drawImage(this.foregroundCanvas, 0, 0); + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const segmenter = this.segmenter; + this.customFrameSource = null; + this.segmenter = null; + const failures = await collectSettledFailures([ + disposeWebSelfieSegmenter(segmenter), + Promise.resolve().then(() => this.maskRefiner?.dispose()), + ]); + throwCollectedFailures({failures, message: 'Canvas camera effect teardown failed'}); + } + + private ensureSize(width: number, height: number): void { + validateCameraEffectFrameDimensions(width, height); + if (this.width === width && this.height === height) { + return; + } + this.width = width; + this.height = height; + this.foregroundCanvas.width = width; + this.foregroundCanvas.height = height; + this.outputCanvas.width = width; + this.outputCanvas.height = height; + this.foregroundContext.imageSmoothingEnabled = true; + this.foregroundContext.imageSmoothingQuality = 'high'; + this.outputContext.imageSmoothingEnabled = true; + this.outputContext.imageSmoothingQuality = 'high'; + this.segmenter?.reset(); + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskReady = false; + this.maskRevision += 1; + } + + private async warmup(): Promise { + const width = this.outputCanvas.width; + const height = this.outputCanvas.height; + validateCameraEffectFrameDimensions(width, height); + const probeCanvas = new OffscreenCanvas(width, height); + const probeContext = probeCanvas.getContext('2d'); + if (probeContext == null) { + throw new Error('Canvas camera effect warm-up requires OffscreenCanvas 2D'); + } + probeContext.fillStyle = '#000'; + probeContext.fillRect(0, 0, width, height); + const frame = new VideoFrame(probeCanvas, {timestamp: 0}); + try { + await this.render(frame, 0); + } finally { + frame.close(); + } + if (this.segmenter != null) { + this.segmenter.reset(); + } + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskReady = false; + } + + private async maybeSegment(source: CanvasImageSource, now: number): Promise { + if (now - this.lastSegmentationAt < WEB_CAMERA_EFFECT_SEGMENTATION_MIN_INTERVAL_MS) { + return; + } + const segmenter = this.segmenter; + if (segmenter == null) { + throw new Error('Camera background rendering requires an initialized segmenter'); + } + this.lastSegmentationAt = now; + resetContext(this.segmentationContext); + this.segmentationContext.drawImage(source, 0, 0, SEG_INPUT_EDGE, SEG_INPUT_EDGE); + const input = this.segmentationContext.getImageData(0, 0, SEG_INPUT_EDGE, SEG_INPUT_EDGE); + await segmenter.segmentIntoMask(input.data, this.maskImage.data); + this.maskContext.putImageData(this.maskImage, 0, 0); + this.maskRevision += 1; + this.maskReady = true; + } + + private drawBackground(source: CanvasImageSource, now: number): void { + const background = this.config.background; + if (background == null) { + throw new Error('Camera background renderer has no configured background'); + } + this.outputContext.globalCompositeOperation = 'copy'; + if (background.mode === CameraBackgroundMode.CUSTOM) { + const customFrameSource = this.customFrameSource; + if (customFrameSource == null) { + throw new Error('Custom camera background frame source is unavailable'); + } + const lease = customFrameSource.acquireFrame(now); + try { + const frame = lease.frame; + const scale = Math.max(this.width / frame.width, this.height / frame.height); + const drawWidth = frame.width * scale; + const drawHeight = frame.height * scale; + this.outputContext.drawImage( + frame.image, + (this.width - drawWidth) / 2, + (this.height - drawHeight) / 2, + drawWidth, + drawHeight, + ); + } finally { + lease.release(); + } + this.outputContext.globalCompositeOperation = 'source-over'; + return; + } + this.outputContext.filter = `blur(${cameraEffectBlurPixels(background.blurStrength)}px)`; + this.outputContext.drawImage(source, 0, 0, this.width, this.height); + this.outputContext.filter = 'none'; + this.outputContext.globalCompositeOperation = 'source-over'; + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource.ts new file mode 100644 index 000000000..4aafbe6d3 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource.ts @@ -0,0 +1,726 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {runWithResponseDeadline} from '@app/features/voice/utils/camera-effects/BoundedResponse'; +import {validateCameraEffectFrameDimensions} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; + +const MAX_ANIMATED_IMAGE_FRAMES = 300; +const MAX_ANIMATED_IMAGE_AGGREGATE_PIXELS = 32_000_000; +const MAX_ANIMATED_IMAGE_DURATION_MICROSECONDS = 60_000_000; +const MIN_ANIMATED_FRAME_DURATION_MICROSECONDS = 20_000; +const MAX_ANIMATED_FRAME_DURATION_MICROSECONDS = 10_000_000; +const MAX_ANIMATED_FRAME_SEARCH_STEPS = 10; +const VIDEO_FIRST_FRAME_TIMEOUT_MS = 8_000; +const VIDEO_LEASE_RELEASE_TIMEOUT_MS = 1_000; + +export const WebCameraEffectCustomFrameSourceKind = Object.freeze({ + STATIC: 'static', + ANIMATED: 'animated', + VIDEO: 'video', +} as const); + +export type WebCameraEffectCustomFrameSourceKind = + (typeof WebCameraEffectCustomFrameSourceKind)[keyof typeof WebCameraEffectCustomFrameSourceKind]; + +export type WebCameraEffectCustomFrameImage = ImageBitmap | VideoFrame; + +export interface WebCameraEffectCustomFrame { + readonly image: WebCameraEffectCustomFrameImage; + readonly width: number; + readonly height: number; + readonly index: number; +} + +export interface WebCameraEffectCustomFrameLease { + readonly frame: WebCameraEffectCustomFrame; + release(): void; +} + +export interface WebCameraEffectCustomFrameSource { + readonly kind: WebCameraEffectCustomFrameSourceKind; + readonly width: number; + readonly height: number; + readonly frameCount: number | null; + acquireFrame(nowMilliseconds: number): WebCameraEffectCustomFrameLease; + dispose(): Promise; +} + +interface MutableWebCameraEffectCustomFrame { + image: WebCameraEffectCustomFrameImage; + width: number; + height: number; + index: number; +} + +class ReusableWebCameraEffectCustomFrameLease implements WebCameraEffectCustomFrameLease { + private value: WebCameraEffectCustomFrame | null = null; + + constructor(private readonly onRelease: () => void) {} + + get frame(): WebCameraEffectCustomFrame { + if (this.value == null) { + throw new Error('Custom camera background frame lease is not active'); + } + return this.value; + } + + get active(): boolean { + return this.value != null; + } + + acquire(frame: WebCameraEffectCustomFrame): WebCameraEffectCustomFrameLease { + if (this.value != null) { + throw new Error('Custom camera background frame source already has an active lease'); + } + this.value = frame; + return this; + } + + release(): void { + if (this.value == null) { + throw new Error('Custom camera background frame lease was released more than once'); + } + this.value = null; + this.onRelease(); + } +} + +class StaticWebCameraEffectCustomFrameSource implements WebCameraEffectCustomFrameSource { + readonly kind = WebCameraEffectCustomFrameSourceKind.STATIC; + readonly width: number; + readonly height: number; + readonly frameCount = 1; + private readonly frame: WebCameraEffectCustomFrame; + private readonly lease = new ReusableWebCameraEffectCustomFrameLease(() => {}); + private disposed = false; + + constructor(image: WebCameraEffectCustomFrameImage) { + const dimensions = customFrameImageDimensions(image); + this.width = dimensions.width; + this.height = dimensions.height; + this.frame = {image, width: this.width, height: this.height, index: 0}; + } + + acquireFrame(nowMilliseconds: number): WebCameraEffectCustomFrameLease { + requireFrameSourceTime(nowMilliseconds); + this.requireActive(); + return this.lease.acquire(this.frame); + } + + async dispose(): Promise { + if (this.disposed) return; + if (this.lease.active) { + throw new Error('Cannot dispose a custom camera background while its frame is leased'); + } + this.disposed = true; + this.frame.image.close(); + } + + private requireActive(): void { + if (this.disposed) { + throw new Error('Cannot read a disposed custom camera background frame source'); + } + } +} + +class AnimatedWebCameraEffectCustomFrameSource implements WebCameraEffectCustomFrameSource { + readonly kind = WebCameraEffectCustomFrameSourceKind.ANIMATED; + readonly width: number; + readonly height: number; + readonly frameCount: number; + private readonly frames: ReadonlyArray; + private readonly frameEndMicroseconds: ReadonlyArray; + private readonly totalDurationMicroseconds: number; + private readonly lease = new ReusableWebCameraEffectCustomFrameLease(() => {}); + private epochMilliseconds: number | null = null; + private disposed = false; + + constructor(frames: ReadonlyArray, frameEndMicroseconds: ReadonlyArray) { + const firstFrame = frames[0]; + if (firstFrame == null) { + throw new Error('Animated custom camera background requires at least one decoded frame'); + } + this.width = firstFrame.displayWidth; + this.height = firstFrame.displayHeight; + this.frameCount = frames.length; + this.frames = frames.map((image, index) => ({image, width: this.width, height: this.height, index})); + this.frameEndMicroseconds = frameEndMicroseconds; + this.totalDurationMicroseconds = frameEndMicroseconds[frameEndMicroseconds.length - 1] ?? 0; + if (this.totalDurationMicroseconds <= 0) { + throw new Error('Animated custom camera background has no positive duration'); + } + } + + acquireFrame(nowMilliseconds: number): WebCameraEffectCustomFrameLease { + requireFrameSourceTime(nowMilliseconds); + this.requireActive(); + return this.lease.acquire(this.requireFrame(this.frameIndexAt(nowMilliseconds))); + } + + async dispose(): Promise { + if (this.disposed) return; + if (this.lease.active) { + throw new Error('Cannot dispose a custom camera background while its frame is leased'); + } + this.disposed = true; + const failures = closeFrameImages(this.frames.map((frame) => frame.image)); + throwCleanupFailures(failures, 'Animated custom camera background teardown failed'); + } + + private frameIndexAt(nowMilliseconds: number): number { + if (nowMilliseconds === 0) return 0; + if (this.epochMilliseconds == null) { + this.epochMilliseconds = nowMilliseconds; + return 0; + } + if (nowMilliseconds < this.epochMilliseconds) { + throw new Error('Custom camera background frame time moved backwards'); + } + const elapsedMicroseconds = Math.floor((nowMilliseconds - this.epochMilliseconds) * 1000); + return this.findFrameIndex(elapsedMicroseconds % this.totalDurationMicroseconds); + } + + private findFrameIndex(positionMicroseconds: number): number { + let low = 0; + let high = this.frameEndMicroseconds.length - 1; + for (let step = 0; step < MAX_ANIMATED_FRAME_SEARCH_STEPS; step += 1) { + if (low >= high) return low; + const middle = low + Math.floor((high - low) / 2); + if ((this.frameEndMicroseconds[middle] ?? 0) > positionMicroseconds) high = middle; + else low = middle + 1; + } + throw new Error('Animated custom camera background frame search exceeded its bound'); + } + + private requireFrame(index: number): WebCameraEffectCustomFrame { + const frame = this.frames[index]; + if (frame == null) { + throw new Error('Animated custom camera background selected an unavailable frame'); + } + return frame; + } + + private requireActive(): void { + if (this.disposed) { + throw new Error('Cannot read a disposed custom camera background frame source'); + } + } +} + +class VideoWebCameraEffectCustomFrameSource implements WebCameraEffectCustomFrameSource { + readonly kind = WebCameraEffectCustomFrameSourceKind.VIDEO; + readonly frameCount = null; + private readonly reader: ReadableStreamDefaultReader; + private readonly lease = new ReusableWebCameraEffectCustomFrameLease(() => this.releaseFrameLease()); + private readonly frame: MutableWebCameraEffectCustomFrame = { + image: null as unknown as VideoFrame, + width: 0, + height: 0, + index: 0, + }; + private readonly firstFramePromise: Promise; + private resolveFirstFrame: (() => void) | null = null; + private rejectFirstFrame: ((error: unknown) => void) | null = null; + private currentFrame: VideoFrame | null = null; + private pendingFrame: VideoFrame | null = null; + private currentFrameIndex = 0; + private pendingFrameIndex = 0; + private failure: unknown = null; + private failed = false; + private nextFrameIndex = 0; + private stopping = false; + private disposed = false; + private leaseReleaseResolve: (() => void) | null = null; + private leaseReleaseTimedOut = false; + private readonly pumpPromise: Promise; + private disposePromise: Promise | null = null; + + constructor(readable: ReadableStream) { + this.reader = readable.getReader(); + this.firstFramePromise = new Promise((resolve, reject) => { + this.resolveFirstFrame = resolve; + this.rejectFirstFrame = reject; + }); + this.pumpPromise = this.pump(); + } + + get width(): number { + return this.currentFrame?.displayWidth ?? 0; + } + + get height(): number { + return this.currentFrame?.displayHeight ?? 0; + } + + async waitForFirstFrame(signal: AbortSignal): Promise { + await waitForAbortablePromise(this.firstFramePromise, signal); + } + + acquireFrame(nowMilliseconds: number): WebCameraEffectCustomFrameLease { + requireFrameSourceTime(nowMilliseconds); + this.requireActive(); + this.promotePendingFrame(); + const currentFrame = this.currentFrame; + if (currentFrame == null) { + throw new Error('Custom camera background video has no current frame'); + } + this.frame.image = currentFrame; + this.frame.width = currentFrame.displayWidth; + this.frame.height = currentFrame.displayHeight; + this.frame.index = this.currentFrameIndex; + return this.lease.acquire(this.frame); + } + + dispose(): Promise { + if (this.disposePromise == null) { + this.disposePromise = this.disposeOwned(); + } + return this.disposePromise; + } + + private async pump(): Promise { + try { + while (!this.stopping) { + const result = await this.reader.read(); + if (result.done) { + if (!this.stopping) throw new Error('Custom camera background video frame stream ended'); + break; + } + if (this.stopping) { + result.value.close(); + break; + } + this.acceptFrame(result.value); + } + } catch (error) { + if (!this.stopping) this.recordFailure(error); + } + } + + private acceptFrame(frame: VideoFrame): void { + try { + validateCameraEffectFrameDimensions(frame.displayWidth, frame.displayHeight); + if (this.currentFrame != null) this.requireStableDimensions(frame); + } catch (error) { + frame.close(); + throw error; + } + const frameIndex = this.takeFrameIndex(); + const currentFrame = this.currentFrame; + if (currentFrame == null) { + this.currentFrame = frame; + this.currentFrameIndex = frameIndex; + this.resolveFirstFrame?.(); + this.clearFirstFrameSettlers(); + return; + } + if (this.lease.active) { + const replaced = this.pendingFrame; + this.pendingFrame = frame; + this.pendingFrameIndex = frameIndex; + replaced?.close(); + return; + } + this.currentFrame = frame; + this.currentFrameIndex = frameIndex; + currentFrame.close(); + } + + private requireStableDimensions(frame: VideoFrame): void { + const currentFrame = this.currentFrame; + if (currentFrame == null) { + throw new Error('Custom camera background video has no current frame for dimension validation'); + } + if (frame.displayWidth !== currentFrame.displayWidth) { + throw new Error('Custom camera background video width changed'); + } + if (frame.displayHeight !== currentFrame.displayHeight) { + throw new Error('Custom camera background video height changed'); + } + } + + private takeFrameIndex(): number { + const frameIndex = this.nextFrameIndex; + this.nextFrameIndex = (this.nextFrameIndex + 1) % Number.MAX_SAFE_INTEGER; + return frameIndex; + } + + private promotePendingFrame(): void { + const pendingFrame = this.pendingFrame; + if (this.lease.active || pendingFrame == null) return; + const replaced = this.currentFrame; + this.currentFrame = pendingFrame; + this.currentFrameIndex = this.pendingFrameIndex; + this.pendingFrame = null; + replaced?.close(); + } + + private releaseFrameLease(): void { + let failure: unknown; + try { + this.promotePendingFrame(); + } catch (error) { + failure = error; + } + this.leaseReleaseResolve?.(); + this.leaseReleaseResolve = null; + if (this.stopping && this.leaseReleaseTimedOut) { + const currentFrame = this.currentFrame; + this.currentFrame = null; + try { + currentFrame?.close(); + } catch (error) { + if (failure !== undefined) { + throw new AggregateError([failure, error], 'Custom camera background video frame release failed'); + } + throw error; + } + } + if (failure !== undefined) throw failure; + } + + private recordFailure(error: unknown): void { + if (this.failed) return; + this.failed = true; + this.failure = error ?? new Error('Custom camera background video frame stream failed without a reason'); + this.rejectFirstFrame?.(this.failure); + this.clearFirstFrameSettlers(); + } + + private clearFirstFrameSettlers(): void { + this.resolveFirstFrame = null; + this.rejectFirstFrame = null; + } + + private requireActive(): void { + if (this.disposed || this.stopping) { + throw new Error('Cannot read a disposed custom camera background video source'); + } + if (this.failed) { + throw this.failure; + } + } + + private async disposeOwned(): Promise { + if (this.disposed) return; + this.stopping = true; + this.rejectFirstFrame?.(new Error('Custom camera background video source was disposed before its first frame')); + this.clearFirstFrameSettlers(); + const cancellation = Promise.resolve().then(() => this.reader.cancel()); + const failures = await settledFailures([cancellation, this.pumpPromise]); + try { + this.reader.releaseLock(); + } catch (error) { + failures.push(error); + } + this.closeOwnedFrame('pendingFrame', failures); + if (this.lease.active) { + try { + await this.waitForLeaseRelease(); + } catch (error) { + this.leaseReleaseTimedOut = true; + failures.push(error); + } + } + if (!this.lease.active) this.closeOwnedFrame('currentFrame', failures); + this.disposed = true; + throwCleanupFailures(failures, 'Custom camera background video teardown failed'); + } + + private closeOwnedFrame(key: 'currentFrame' | 'pendingFrame', failures: Array): void { + const frame = this[key]; + this[key] = null; + if (frame == null) return; + try { + frame.close(); + } catch (error) { + failures.push(error); + } + } + + private waitForLeaseRelease(): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.leaseReleaseResolve = null; + reject(new Error('Custom camera background video frame lease exceeded its release deadline')); + }, VIDEO_LEASE_RELEASE_TIMEOUT_MS); + this.leaseReleaseResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); + } +} + +function customFrameImageDimensions(image: WebCameraEffectCustomFrameImage): {width: number; height: number} { + if ('displayWidth' in image) { + return {width: image.displayWidth, height: image.displayHeight}; + } + return {width: image.width, height: image.height}; +} + +function requireFrameSourceTime(nowMilliseconds: number): void { + if (!Number.isFinite(nowMilliseconds) || nowMilliseconds < 0) { + throw new Error('Custom camera background frame time must be finite and non-negative'); + } +} + +function closeFrameImages(images: ReadonlyArray): Array { + const failures: Array = []; + for (const image of images) { + try { + image.close(); + } catch (error) { + failures.push(error); + } + } + return failures; +} + +function throwCleanupFailures(failures: ReadonlyArray, message: string): void { + if (failures.length === 0) return; + if (failures.length === 1) throw failures[0]; + throw new AggregateError(failures, message); +} + +function throwWithCleanupFailures(error: unknown, failures: ReadonlyArray, message: string): never { + if (failures.length === 0) throw error; + throw new AggregateError([error, ...failures], message); +} + +async function settledFailures(promises: ReadonlyArray>): Promise> { + const outcomes = await Promise.allSettled(promises); + const failures: Array = []; + for (const outcome of outcomes) { + if (outcome.status === 'rejected') failures.push(outcome.reason); + } + return failures; +} + +function waitForAbortablePromise(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const handleAbort = (): void => reject(signal.reason); + signal.addEventListener('abort', handleAbort, {once: true}); + promise.then( + (value) => { + signal.removeEventListener('abort', handleAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', handleAbort); + reject(error); + }, + ); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason ?? new Error('Custom camera background operation was aborted'); +} + +function requireAnimatedFrameDuration(duration: number | null): number { + if (duration == null || !Number.isSafeInteger(duration) || duration < 0) { + throw new Error('Animated custom camera background has an invalid frame duration'); + } + const normalizedDuration = Math.max(duration, MIN_ANIMATED_FRAME_DURATION_MICROSECONDS); + if (normalizedDuration > MAX_ANIMATED_FRAME_DURATION_MICROSECONDS) { + throw new Error('Animated custom camera background frame duration exceeds the supported maximum'); + } + return normalizedDuration; +} + +function validateAnimatedImageBudget(width: number, height: number, frameCount: number): void { + validateCameraEffectFrameDimensions(width, height); + const aggregatePixels = width * height * frameCount; + if (!Number.isSafeInteger(aggregatePixels)) { + throw new Error('Animated custom camera background decoded pixel budget is invalid'); + } + if (aggregatePixels > MAX_ANIMATED_IMAGE_AGGREGATE_PIXELS) { + throw new Error('Animated custom camera background exceeds the aggregate decoded pixel budget'); + } +} + +function validateDecodedFrameDimensions(frame: VideoFrame, width: number, height: number): void { + validateCameraEffectFrameDimensions(frame.displayWidth, frame.displayHeight); + if (frame.displayWidth !== width || frame.displayHeight !== height) { + throw new Error('Animated custom camera background frame dimensions changed during decoding'); + } +} + +async function decodeFrames( + decoder: ImageDecoder, + frameCount: number, + animated: boolean, + signal: AbortSignal, +): Promise { + if (!animated && frameCount !== 1) { + throw new Error('Static custom camera background unexpectedly contains multiple frames'); + } + const frames: Array = []; + const frameEndMicroseconds: Array = []; + let width = 0; + let height = 0; + let totalDurationMicroseconds = 0; + let decodedFrame: VideoFrame | null = null; + try { + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + throwIfAborted(signal); + const result = await decoder.decode({frameIndex, completeFramesOnly: true}); + decodedFrame = result.image; + throwIfAborted(signal); + if (!result.complete) { + throw new Error('Animated custom camera background produced an incomplete decoded frame'); + } + if (frameIndex === 0) { + width = decodedFrame.displayWidth; + height = decodedFrame.displayHeight; + validateAnimatedImageBudget(width, height, frameCount); + } + validateDecodedFrameDimensions(decodedFrame, width, height); + if (animated) { + totalDurationMicroseconds += requireAnimatedFrameDuration(decodedFrame.duration); + if (totalDurationMicroseconds > MAX_ANIMATED_IMAGE_DURATION_MICROSECONDS) { + throw new Error('Animated custom camera background exceeds the supported duration'); + } + frameEndMicroseconds.push(totalDurationMicroseconds); + } + frames.push(decodedFrame); + decodedFrame = null; + } + } catch (error) { + const failures = closeFrameImages(decodedFrame == null ? frames : [decodedFrame, ...frames]); + throwWithCleanupFailures(error, failures, 'Animated custom camera background decoding failed during cleanup'); + } + const firstFrame = frames[0]; + if (firstFrame == null) { + throw new Error('Animated custom camera background decoding produced no frames'); + } + if (!animated) return new StaticWebCameraEffectCustomFrameSource(firstFrame); + try { + return new AnimatedWebCameraEffectCustomFrameSource(frames, frameEndMicroseconds); + } catch (error) { + const failures = closeFrameImages(frames); + throwWithCleanupFailures(error, failures, 'Animated custom camera background setup failed during cleanup'); + } +} + +type CameraImageDecoderConstructor = { + new (init: ImageDecoderInit): ImageDecoder; + isTypeSupported(type: string): Promise; +}; + +function getCameraImageDecoderConstructor(): CameraImageDecoderConstructor | null { + const candidate: unknown = Reflect.get(globalThis, 'ImageDecoder'); + if (typeof candidate !== 'function') return null; + if (typeof Reflect.get(candidate, 'isTypeSupported') !== 'function') return null; + return candidate as CameraImageDecoderConstructor; +} + +async function decodeImageDecoderSource( + blob: Blob, + mediaType: string, + signal: AbortSignal, +): Promise { + const ImageDecoderConstructor = getCameraImageDecoderConstructor(); + if (ImageDecoderConstructor == null) { + throw new Error('Animated custom camera backgrounds require ImageDecoder'); + } + if (!(await ImageDecoderConstructor.isTypeSupported(mediaType))) { + throw new Error(`Animated custom camera background type is unsupported: ${mediaType}`); + } + throwIfAborted(signal); + const decoder = new ImageDecoderConstructor({data: blob.stream(), type: mediaType, preferAnimation: true}); + let decoderClosed = false; + let source: WebCameraEffectCustomFrameSource | null = null; + const closeDecoder = (): void => { + if (decoderClosed) return; + decoderClosed = true; + decoder.close(); + }; + const handleAbort = (): void => closeDecoder(); + signal.addEventListener('abort', handleAbort, {once: true}); + try { + await Promise.all([decoder.tracks.ready, decoder.completed]); + throwIfAborted(signal); + const selectedTrack = decoder.tracks.selectedTrack; + if (selectedTrack == null) { + throw new Error('Animated custom camera background has no selected image track'); + } + const frameCount = selectedTrack.frameCount; + if (!Number.isSafeInteger(frameCount) || frameCount <= 0) { + throw new Error('Animated custom camera background has an invalid frame count'); + } + if (frameCount > MAX_ANIMATED_IMAGE_FRAMES) { + throw new Error('Animated custom camera background exceeds the supported frame count'); + } + const animated = mediaType === 'image/gif' || selectedTrack.animated; + source = await decodeFrames(decoder, frameCount, animated, signal); + closeDecoder(); + return source; + } catch (error) { + const failures: Array = []; + try { + closeDecoder(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (source != null) { + try { + await source.dispose(); + } catch (cleanupError) { + failures.push(cleanupError); + } + } + throwWithCleanupFailures(error, failures, 'Animated custom camera background initialization failed during cleanup'); + } finally { + signal.removeEventListener('abort', handleAbort); + } +} + +async function decodeStaticSource(blob: Blob, signal: AbortSignal): Promise { + const image = await createImageBitmap(blob); + try { + throwIfAborted(signal); + validateCameraEffectFrameDimensions(image.width, image.height); + return new StaticWebCameraEffectCustomFrameSource(image); + } catch (error) { + const failures = closeFrameImages([image]); + throwWithCleanupFailures(error, failures, 'Custom camera background validation failed during cleanup'); + } +} + +export function createWebCameraEffectImageFrameSource( + blob: Blob, + mediaType: string, + signal: AbortSignal, +): Promise { + if (mediaType === 'image/gif' || mediaType === 'image/webp') { + return decodeImageDecoderSource(blob, mediaType, signal); + } + return decodeStaticSource(blob, signal); +} + +export async function createWebCameraEffectVideoFrameSource( + readable: ReadableStream, +): Promise { + const source = new VideoWebCameraEffectCustomFrameSource(readable); + try { + await runWithResponseDeadline({ + timeoutMilliseconds: VIDEO_FIRST_FRAME_TIMEOUT_MS, + description: 'Custom camera background video first frame', + signal: null, + operation: (signal) => source.waitForFirstFrame(signal), + }); + return source; + } catch (error) { + try { + await source.dispose(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Custom camera background video initialization failed during cleanup', + ); + } + throw error; + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomImage.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomImage.ts new file mode 100644 index 000000000..c17557a87 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectCustomImage.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + cancelResponseBodyAndThrow, + readBoundedResponseBlob, + runWithResponseDeadline, +} from '@app/features/voice/utils/camera-effects/BoundedResponse'; +import {createWebCameraEffectImageFrameSource} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource'; + +export type { + WebCameraEffectCustomFrame, + WebCameraEffectCustomFrameImage, + WebCameraEffectCustomFrameLease, + WebCameraEffectCustomFrameSource, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource'; +export { + createWebCameraEffectVideoFrameSource, + WebCameraEffectCustomFrameSourceKind, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource'; + +import type {WebCameraEffectCustomFrameSource} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomFrameSource'; + +const MAX_CUSTOM_MEDIA_BYTES = 10 * 1024 * 1024; +const MAX_CUSTOM_MEDIA_URL_LENGTH = 16 * 1024; +const MAX_CUSTOM_MEDIA_RESPONSE_CHUNKS = 4096; +export const WEB_CAMERA_EFFECT_CUSTOM_MEDIA_OPERATION_TIMEOUT_MS = 8_000; + +const SUPPORTED_IMAGE_MEDIA_TYPES = new Set(['image/gif', 'image/jpeg', 'image/png', 'image/webp']); + +export function requireWebCameraEffectCustomMediaURL(URL: string): void { + if (URL.length === 0 || URL.length > MAX_CUSTOM_MEDIA_URL_LENGTH) { + throw new Error('Custom camera background URL has an invalid length'); + } +} + +function throwIfCustomMediaOperationAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw signal.reason ?? new Error('Custom camera background operation was aborted'); + } +} + +export async function readWebCameraEffectCustomMediaBlob(URL: string, signal: AbortSignal): Promise { + requireWebCameraEffectCustomMediaURL(URL); + throwIfCustomMediaOperationAborted(signal); + let response: Response; + try { + response = await fetch(URL, { + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal, + }); + } catch { + if (signal.aborted) throw signal.reason; + throw new Error('Custom camera background request failed'); + } + if (!response.ok) { + await cancelResponseBodyAndThrow({ + response, + error: new Error(`Custom camera background request failed with status ${response.status}`), + description: 'Custom camera background response', + }); + } + const blob = await readBoundedResponseBlob({ + response, + maximumBytes: MAX_CUSTOM_MEDIA_BYTES, + maximumChunks: MAX_CUSTOM_MEDIA_RESPONSE_CHUNKS, + description: 'Custom camera background response', + }); + throwIfCustomMediaOperationAborted(signal); + if (blob.size === 0) { + throw new Error('Custom camera background response is empty'); + } + return blob; +} + +function normalizedMediaType(value: string): string { + return value.split(';', 1)[0]?.trim().toLowerCase() ?? ''; +} + +function bytesEqualAt(bytes: Uint8Array, offset: number, expected: ReadonlyArray): boolean { + if (bytes.byteLength < offset + expected.length) return false; + for (let index = 0; index < expected.length; index += 1) { + if (bytes[offset + index] !== expected[index]) return false; + } + return true; +} + +function sniffImageMediaType(bytes: Uint8Array): string | null { + if (bytesEqualAt(bytes, 0, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61])) return 'image/gif'; + if (bytesEqualAt(bytes, 0, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) return 'image/gif'; + if (bytesEqualAt(bytes, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg'; + if (bytesEqualAt(bytes, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'; + if (bytesEqualAt(bytes, 0, [0x52, 0x49, 0x46, 0x46]) && bytesEqualAt(bytes, 8, [0x57, 0x45, 0x42, 0x50])) { + return 'image/webp'; + } + return null; +} + +export async function resolveWebCameraEffectCustomImageMediaType(blob: Blob, signal: AbortSignal): Promise { + const header = new Uint8Array(await blob.slice(0, 12).arrayBuffer()); + throwIfCustomMediaOperationAborted(signal); + const detectedMediaType = sniffImageMediaType(header); + if (detectedMediaType == null) { + throw new Error('Custom camera background is not a supported image format'); + } + const declaredMediaType = normalizedMediaType(blob.type); + if (SUPPORTED_IMAGE_MEDIA_TYPES.has(declaredMediaType) && declaredMediaType !== detectedMediaType) { + throw new Error('Custom camera background media type does not match its encoded data'); + } + return detectedMediaType; +} + +export async function loadWebCameraEffectCustomFrameSource(URL: string): Promise { + requireWebCameraEffectCustomMediaURL(URL); + return runWithResponseDeadline({ + timeoutMilliseconds: WEB_CAMERA_EFFECT_CUSTOM_MEDIA_OPERATION_TIMEOUT_MS, + description: 'Custom camera background initialization', + signal: null, + operation: async (signal) => { + const blob = await readWebCameraEffectCustomMediaBlob(URL, signal); + const mediaType = await resolveWebCameraEffectCustomImageMediaType(blob, signal); + return createWebCameraEffectImageFrameSource(blob, mediaType, signal); + }, + }); +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectMask.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectMask.ts new file mode 100644 index 000000000..c3f46f785 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectMask.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const WEB_CAMERA_EFFECT_MASK_VOID_MAX = 0.3; +export const WEB_CAMERA_EFFECT_MASK_CORE_MIN = 0.7; +export const WEB_CAMERA_EFFECT_MASK_BAND_WIDTH = 0.4; +export const WEB_CAMERA_EFFECT_MASK_SPECKLE_NEIGHBOUR_MAX = 0.28; +export const WEB_CAMERA_EFFECT_MASK_HOLE_NEIGHBOUR_MIN = 0.72; +export const WEB_CAMERA_EFFECT_MASK_CORE_GROW_MIN = 0.45; +export const WEB_CAMERA_EFFECT_MASK_TEMPORAL_KEEP_STILL = 0.55; +export const WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW = 0.1; +export const WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_HIGH = 0.35; +export const WEB_CAMERA_EFFECT_MASK_BAND_EPSILON = 0.004; +export const WEB_CAMERA_EFFECT_MASK_EDGE_SOFTNESS = 0.25; +export const WEB_CAMERA_EFFECT_MASK_GUIDE_SPATIAL_FALLOFF = 1.5; +export const WEB_CAMERA_EFFECT_MASK_GUIDE_RANGE_FALLOFF = 18; + +export function shapeWebCameraEffectMaskAlpha(value: number): number { + const normalized = Math.max( + 0, + Math.min(1, (value - WEB_CAMERA_EFFECT_MASK_VOID_MAX) / WEB_CAMERA_EFFECT_MASK_BAND_WIDTH), + ); + return normalized * normalized * (3 - 2 * normalized); +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectPipeline.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectPipeline.ts new file mode 100644 index 000000000..48079fc3b --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectPipeline.ts @@ -0,0 +1,940 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {Logger} from '@app/features/platform/utils/AppLogger'; +import { + collectSettledFailures, + throwCollectedFailures, +} from '@app/features/voice/utils/camera-effects/AggregateOperations'; +import { + CameraBackgroundMode, + clampVideoFrameRate, +} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import { + type ErrorDiagnostic, + type ErrorDiagnosticType, + getErrorDiagnosticType, + isErrorDiagnostic, + isErrorDiagnosticType, +} from '@app/features/voice/utils/camera-effects/ErrorDiagnostic'; +import {createTrackProcessor} from '@app/features/voice/utils/camera-effects/MediaStreamTrackProcessorPolyfill'; +import {detectWebCameraSegmentationCapability} from '@app/features/voice/utils/camera-effects/WebCameraBackgroundSupport'; +import { + isWebCameraEffectShutdownReason, + validateCameraEffectFrameDimensions, + WEB_CAMERA_EFFECT_UPDATE_REQUEST_ID_MAX, + WebCameraEffectBackend, + WebCameraEffectCommandKind, + WebCameraEffectCustomMediaKind, + WebCameraEffectEventKind, + WebCameraEffectShutdownReason, + type WebCameraEffectStartCommand, + type WebCameraEffectUpdateCommand, + type WebCameraEffectUpdateFailedEvent, + type WebCameraEffectWorkerEvent, + type WebCameraPipelineConfig, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; +import { + createWebCameraEffectVideoFrameProducer, + type WebCameraEffectVideoFrameProducer, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectVideoFrameSource'; + +const VIDEO_TRACK_KIND = 'video'; + +class InvalidCameraEffectReadyEventError extends Error { + constructor() { + super('Camera effect worker emitted an invalid ready event'); + this.name = 'InvalidCameraEffectReadyEventError'; + } +} + +const logger = new Logger('WebCameraEffectPipeline'); + +const DEFAULT_OUTPUT_FRAME_RATE = 30; +const INITIALIZATION_TIMEOUT_MS = 30_000; +const SHUTDOWN_TIMEOUT_MS = 2_000; +const UPDATE_TIMEOUT_MS = 12_000; + +const WebGPUProbeState = Object.freeze({ + UNKNOWN: 'unknown', + PROBING: 'probing', + ENABLED: 'enabled', + DISABLED: 'disabled', +} as const); + +type WebGPUProbeState = (typeof WebGPUProbeState)[keyof typeof WebGPUProbeState]; + +let webGPUProbeState: WebGPUProbeState = WebGPUProbeState.UNKNOWN; + +interface CapturedSurface { + readonly offscreen: OffscreenCanvas; + readonly track: MediaStreamTrack; +} + +interface InitializationResult { + readonly backend: WebCameraEffectBackend; + readonly fallbackErrorType: ErrorDiagnosticType | null; +} + +interface PendingUpdate { + readonly requestId: number; + readonly config: WebCameraPipelineConfig; + readonly candidateVideoProducer: WebCameraEffectVideoFrameProducer | null; + readonly ownsCandidateVideoProducer: boolean; + readonly resolve: () => void; + readonly reject: (error: Error) => void; + readonly timeout: number; +} + +export interface WebCameraPipeline { + readonly outputTrack: MediaStreamTrack; + readonly backend: WebCameraEffectBackend; + assertActive(): void; + updateConfig(config: WebCameraPipelineConfig): Promise; + beginStop(): void; + stop(): void; +} + +export interface WebCameraEffectPipelineCreateRequest { + readonly source: MediaStreamTrack; + readonly config: WebCameraPipelineConfig; + readonly onFailure: (pipeline: WebCameraPipeline, error: Error) => void; +} + +function parseCameraEffectWorkerEvent(value: unknown): WebCameraEffectWorkerEvent { + if (value == null || typeof value !== 'object') { + throw new Error('Camera effect worker emitted a non-object event'); + } + const record = value as Record; + if (record.kind === WebCameraEffectEventKind.READY) { + if ( + record.backend !== WebCameraEffectBackend.WEB_GPU && + record.backend !== WebCameraEffectBackend.WASM_WORKER && + record.backend !== WebCameraEffectBackend.CANVAS_WORKER + ) { + throw new InvalidCameraEffectReadyEventError(); + } + if (!('fallbackErrorType' in record)) { + throw new InvalidCameraEffectReadyEventError(); + } + const fallbackErrorType = record.fallbackErrorType; + let resolvedFallbackErrorType: ErrorDiagnosticType | null = null; + if (!Object.is(fallbackErrorType, null)) { + if (!isErrorDiagnosticType(fallbackErrorType)) { + throw new InvalidCameraEffectReadyEventError(); + } + resolvedFallbackErrorType = fallbackErrorType; + } + return { + kind: WebCameraEffectEventKind.READY, + backend: record.backend, + fallbackErrorType: resolvedFallbackErrorType, + }; + } + if (record.kind === WebCameraEffectEventKind.UPDATED) { + if (!isValidUpdateRequestId(record.requestId)) { + throw new Error('Camera effect worker emitted an invalid update acknowledgement'); + } + return {kind: WebCameraEffectEventKind.UPDATED, requestId: record.requestId}; + } + if (record.kind === WebCameraEffectEventKind.UPDATE_FAILED) { + if (!isValidUpdateRequestId(record.requestId) || !isErrorDiagnostic(record)) { + throw new Error('Camera effect worker emitted an invalid update rejection'); + } + return { + kind: WebCameraEffectEventKind.UPDATE_FAILED, + requestId: record.requestId, + errorType: record.errorType, + message: record.message, + stack: record.stack, + }; + } + if (record.kind === WebCameraEffectEventKind.FAILED) { + if (!isErrorDiagnostic(record)) { + throw new Error('Camera effect worker emitted an invalid failure event'); + } + return { + kind: WebCameraEffectEventKind.FAILED, + errorType: record.errorType, + message: record.message, + stack: record.stack, + }; + } + if (record.kind === WebCameraEffectEventKind.STOPPED) { + const reason = record.reason; + if (!isWebCameraEffectShutdownReason(reason)) { + throw new Error('Camera effect worker emitted a stop event without a valid reason'); + } + return { + kind: WebCameraEffectEventKind.STOPPED, + reason, + diagnostic: parseOptionalErrorDiagnostic(record.diagnostic), + }; + } + throw new Error('Camera effect worker emitted an unknown event'); +} + +function parseOptionalErrorDiagnostic(value: unknown): ErrorDiagnostic | null { + if (value == null) { + return null; + } + if (!isErrorDiagnostic(value)) { + throw new Error('Camera effect worker emitted an invalid stop diagnostic'); + } + return {errorType: value.errorType, message: value.message, stack: value.stack}; +} + +function isValidUpdateRequestId(value: unknown): value is number { + if (!Number.isSafeInteger(value)) { + return false; + } + if ((value as number) <= 0) { + return false; + } + return (value as number) <= WEB_CAMERA_EFFECT_UPDATE_REQUEST_ID_MAX; +} + +function createWorkerReportedError(event: ErrorDiagnostic, context: string): Error { + const error = new Error(`${context}: ${event.message}`); + if (event.stack != null) { + error.stack = `${error.stack ?? error.message}\nWorker stack:\n${event.stack}`; + } + return error; +} + +function throwAfterTrackCleanup(error: unknown, tracks: ReadonlyArray, message: string): never { + const failures: Array = [error]; + for (const track of tracks) { + try { + track.stop(); + } catch (cleanupError) { + failures.push(cleanupError); + } + } + throwCollectedFailures({failures, message: message}); + throw error; +} + +function createCapturedSurface(frameRate: number, width: number, height: number): CapturedSurface { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const stream = canvas.captureStream(frameRate); + const track = stream.getVideoTracks()[0]; + if (track == null) { + throw new Error('Camera effect canvas capture produced no video track'); + } + try { + return {offscreen: canvas.transferControlToOffscreen(), track}; + } catch (error) { + throwAfterTrackCleanup(error, [track], 'Camera effect surface creation failed during cleanup'); + } +} + +function initialSurfaceDimension(value: number | null): number { + if (value == null) { + return 1; + } + if (!Number.isSafeInteger(value)) { + return 1; + } + if (value <= 0) { + return 1; + } + return value; +} + +function hasVideoBackground(config: WebCameraPipelineConfig): boolean { + const background = config.background; + if (background == null || background.mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + return background.customMediaKind === WebCameraEffectCustomMediaKind.VIDEO; +} + +function hasSameVideoBackground(current: WebCameraPipelineConfig, next: WebCameraPipelineConfig): boolean { + if (!hasVideoBackground(current) || !hasVideoBackground(next)) { + return false; + } + const currentBackground = current.background; + const nextBackground = next.background; + if (currentBackground?.mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + if (nextBackground?.mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + return currentBackground.customMediaURL === nextBackground.customMediaURL; +} + +async function createVideoFrameProducer( + config: WebCameraPipelineConfig, +): Promise { + const background = config.background; + if (background == null || background.mode !== CameraBackgroundMode.CUSTOM) { + return null; + } + if (background.customMediaKind !== WebCameraEffectCustomMediaKind.VIDEO) { + return null; + } + return createWebCameraEffectVideoFrameProducer(background.customMediaURL); +} + +function selectWebGPUPreference(config: WebCameraPipelineConfig): boolean { + if (config.background == null) { + return false; + } + if (webGPUProbeState === WebGPUProbeState.UNKNOWN) { + webGPUProbeState = WebGPUProbeState.PROBING; + return true; + } + return webGPUProbeState === WebGPUProbeState.ENABLED; +} + +function recordWebGPUSelection(preferred: boolean, backend: WebCameraEffectBackend): void { + if (!preferred) { + return; + } + if (backend === WebCameraEffectBackend.WEB_GPU) { + webGPUProbeState = WebGPUProbeState.ENABLED; + return; + } + webGPUProbeState = WebGPUProbeState.DISABLED; +} + +function releaseWebGPUProbe(preferred: boolean): void { + if (preferred && webGPUProbeState === WebGPUProbeState.PROBING) { + webGPUProbeState = WebGPUProbeState.UNKNOWN; + } +} + +export class WebCameraEffectPipeline implements WebCameraPipeline { + private readonly worker: Worker; + private readonly gpuTrack: MediaStreamTrack; + private readonly fallbackTrack: MediaStreamTrack; + private readonly onFailure: (pipeline: WebCameraPipeline, error: Error) => void; + private readonly preferWebGPU: boolean; + private readonly initializationPromise: Promise; + private readonly resolveInitialization: (result: InitializationResult) => void; + private readonly rejectInitialization: (error: Error) => void; + private initializationTimeout: number | null; + private shutdownTimeout: number | null = null; + private selectedTrack: MediaStreamTrack | null = null; + private selectedBackend: WebCameraEffectBackend | null = null; + private operationalFailure: Error | null = null; + private currentConfig: WebCameraPipelineConfig; + private currentVideoProducer: WebCameraEffectVideoFrameProducer | null = null; + private pendingUpdate: PendingUpdate | null = null; + private nextUpdateRequestId = 1; + private updatePreparationActive = false; + private stopped = false; + private stopIntent = false; + private initialized = false; + + private constructor( + worker: Worker, + GPUTrack: MediaStreamTrack, + fallbackTrack: MediaStreamTrack, + onFailure: (pipeline: WebCameraPipeline, error: Error) => void, + config: WebCameraPipelineConfig, + preferWebGPU: boolean, + ) { + this.worker = worker; + this.gpuTrack = GPUTrack; + this.fallbackTrack = fallbackTrack; + this.onFailure = onFailure; + this.currentConfig = config; + this.preferWebGPU = preferWebGPU; + let capturedResolveInitialization: ((result: InitializationResult) => void) | null = null; + let capturedRejectInitialization: ((error: Error) => void) | null = null; + this.initializationPromise = new Promise((resolve, reject) => { + capturedResolveInitialization = resolve; + capturedRejectInitialization = reject; + }); + const resolveInitialization = capturedResolveInitialization; + if (resolveInitialization == null) { + throw new Error('camera_effect_initialization_resolve_not_captured'); + } + const rejectInitialization = capturedRejectInitialization; + if (rejectInitialization == null) { + throw new Error('camera_effect_initialization_reject_not_captured'); + } + this.resolveInitialization = resolveInitialization; + this.rejectInitialization = rejectInitialization; + this.initializationTimeout = window.setTimeout(() => { + this.failInitialization(new Error('Camera effect worker initialization timed out')); + }, INITIALIZATION_TIMEOUT_MS); + worker.addEventListener('message', this.handleWorkerMessage); + worker.addEventListener('error', this.handleWorkerError); + worker.addEventListener('messageerror', this.handleWorkerMessageError); + } + + get outputTrack(): MediaStreamTrack { + if (this.selectedTrack == null) { + throw new Error('Camera effect output track requested before initialization'); + } + this.assertActive(); + return this.selectedTrack; + } + + get backend(): WebCameraEffectBackend { + if (this.selectedBackend == null) { + throw new Error('Camera effect backend requested before initialization'); + } + this.assertActive(); + return this.selectedBackend; + } + + static async create({ + source, + config, + onFailure, + }: WebCameraEffectPipelineCreateRequest): Promise { + if (source.kind !== VIDEO_TRACK_KIND) { + throw new Error('Camera effect pipeline requires a video track'); + } + const capability = detectWebCameraSegmentationCapability(); + if (!capability.available) { + throw new Error(capability.reason); + } + const settings = source.getSettings(); + let configuredFrameRate = settings.frameRate; + if (configuredFrameRate == null) { + configuredFrameRate = DEFAULT_OUTPUT_FRAME_RATE; + } + const frameRate = clampVideoFrameRate(configuredFrameRate); + let configuredWidth: number | null = null; + if (settings.width != null) { + configuredWidth = settings.width; + } + let configuredHeight: number | null = null; + if (settings.height != null) { + configuredHeight = settings.height; + } + const width = initialSurfaceDimension(configuredWidth); + const height = initialSurfaceDimension(configuredHeight); + validateCameraEffectFrameDimensions(width, height); + const GPUSurface = createCapturedSurface(frameRate, width, height); + let fallbackSurface: CapturedSurface; + try { + fallbackSurface = createCapturedSurface(frameRate, width, height); + } catch (error) { + throwAfterTrackCleanup( + error, + [GPUSurface.track], + 'Camera effect fallback surface creation failed during cleanup', + ); + } + let worker: Worker; + try { + worker = new Worker(new URL('./WebCameraEffectWorker.ts', import.meta.url), {type: 'module'}); + } catch (error) { + throwAfterTrackCleanup( + error, + [GPUSurface.track, fallbackSurface.track], + 'Camera effect worker creation failed during cleanup', + ); + } + const preferWebGPU = selectWebGPUPreference(config); + const pipeline = new WebCameraEffectPipeline( + worker, + GPUSurface.track, + fallbackSurface.track, + onFailure, + config, + preferWebGPU, + ); + let readable: ReadableStream | null = null; + try { + pipeline.currentVideoProducer = await createVideoFrameProducer(config); + readable = createTrackProcessor(source).readable; + const customBackgroundFrames = pipeline.currentVideoProducer?.readable ?? null; + const command: WebCameraEffectStartCommand = { + kind: WebCameraEffectCommandKind.START, + readable, + customBackgroundFrames, + gpuCanvas: GPUSurface.offscreen, + fallbackCanvas: fallbackSurface.offscreen, + config, + preferWebGPU, + }; + const transfer: Array = [readable, GPUSurface.offscreen, fallbackSurface.offscreen]; + if (customBackgroundFrames != null) { + transfer.push(customBackgroundFrames); + } + worker.postMessage(command, transfer); + } catch (error) { + const primaryError = new Error('Camera effect pipeline could not prepare or transfer its input streams', { + cause: error, + }); + let failures: ReadonlyArray = []; + if (readable != null) { + failures = await collectSettledFailures([readable.cancel(primaryError)]); + } + let initializationError: Error = primaryError; + if (failures.length > 0) { + initializationError = new AggregateError( + [primaryError, ...failures], + 'Camera effect pipeline initialization cleanup failed', + ); + } + pipeline.failInitialization(initializationError); + } + const initialization = await pipeline.initializationPromise; + if (initialization.fallbackErrorType != null) { + logger.debug('voice.camera_effect_backend.fallback_selected', { + errorType: initialization.fallbackErrorType, + }); + } else { + logger.info('voice.camera_effect_worker.initialized', {backend: initialization.backend}); + } + return pipeline; + } + + assertActive(): void { + if (this.operationalFailure != null) { + throw this.operationalFailure; + } + if (this.stopped || !this.initialized) { + throw new Error('Camera effect pipeline is inactive'); + } + } + + async updateConfig(config: WebCameraPipelineConfig): Promise { + this.assertActive(); + if (this.updatePreparationActive || this.pendingUpdate != null) { + throw new Error('Camera effect configuration update is already in progress'); + } + this.updatePreparationActive = true; + let candidateVideoProducer: WebCameraEffectVideoFrameProducer | null = null; + let ownsCandidateVideoProducer = false; + try { + if (hasSameVideoBackground(this.currentConfig, config)) { + candidateVideoProducer = this.currentVideoProducer; + } else { + candidateVideoProducer = await createVideoFrameProducer(config); + ownsCandidateVideoProducer = candidateVideoProducer != null; + } + this.assertActive(); + } catch (error) { + if (ownsCandidateVideoProducer) { + candidateVideoProducer?.stop(); + } + this.updatePreparationActive = false; + throw error; + } + const requestId = this.takeUpdateRequestId(); + let resolveUpdate: (() => void) | null = null; + let rejectUpdate: ((error: Error) => void) | null = null; + const completion = new Promise((resolve, reject) => { + resolveUpdate = resolve; + rejectUpdate = reject; + }); + if (resolveUpdate == null || rejectUpdate == null) { + if (ownsCandidateVideoProducer) { + candidateVideoProducer?.stop(); + } + this.updatePreparationActive = false; + throw new Error('Camera effect update promise handlers were not captured'); + } + const timeout = window.setTimeout(() => { + const timeoutError = new Error('Camera effect configuration update timed out'); + this.rejectPendingUpdate(timeoutError); + this.stopAfterWorkerFailure(timeoutError); + }, UPDATE_TIMEOUT_MS); + this.pendingUpdate = { + requestId, + config, + candidateVideoProducer, + ownsCandidateVideoProducer, + resolve: resolveUpdate, + reject: rejectUpdate, + timeout, + }; + this.updatePreparationActive = false; + const customBackgroundFrames = ownsCandidateVideoProducer ? (candidateVideoProducer?.readable ?? null) : null; + const command: WebCameraEffectUpdateCommand = { + kind: WebCameraEffectCommandKind.UPDATE, + requestId, + config, + customBackgroundFrames, + }; + try { + const transfer: Array = []; + if (customBackgroundFrames != null) { + transfer.push(customBackgroundFrames); + } + this.worker.postMessage(command, transfer); + } catch (error) { + const updateError = new Error('Camera effect worker could not receive its updated configuration', { + cause: error, + }); + this.rejectPendingUpdate(updateError); + this.stopAfterWorkerFailure(updateError); + } + return completion; + } + + private takeUpdateRequestId(): number { + const requestId = this.nextUpdateRequestId; + this.nextUpdateRequestId += 1; + if (this.nextUpdateRequestId > WEB_CAMERA_EFFECT_UPDATE_REQUEST_ID_MAX) { + this.nextUpdateRequestId = 1; + } + return requestId; + } + + private completeUpdate(requestId: number): void { + const pending = this.pendingUpdate; + if (pending == null || pending.requestId !== requestId) { + this.stopAfterWorkerFailure(new Error('Camera effect worker acknowledged an unknown update')); + return; + } + window.clearTimeout(pending.timeout); + this.pendingUpdate = null; + const previousVideoProducer = this.currentVideoProducer; + this.currentConfig = pending.config; + this.currentVideoProducer = pending.candidateVideoProducer; + pending.resolve(); + if (previousVideoProducer !== pending.candidateVideoProducer) { + try { + previousVideoProducer?.stop(); + } catch (error) { + const cleanupError = new Error('Camera effect update committed but previous video source cleanup failed', { + cause: error, + }); + this.stopAfterWorkerFailure(cleanupError); + return; + } + } + } + + private rejectUpdate(event: WebCameraEffectUpdateFailedEvent): void { + const pending = this.pendingUpdate; + if (pending == null || pending.requestId !== event.requestId) { + this.stopAfterWorkerFailure(new Error('Camera effect worker rejected an unknown update')); + return; + } + this.rejectPendingUpdate(createWorkerReportedError(event, 'Camera effect configuration update failed')); + } + + private rejectPendingUpdate(error: Error): void { + const pending = this.pendingUpdate; + if (pending == null) { + return; + } + window.clearTimeout(pending.timeout); + this.pendingUpdate = null; + let rejection = error; + if (pending.ownsCandidateVideoProducer) { + try { + pending.candidateVideoProducer?.stop(); + } catch (cleanupError) { + rejection = new AggregateError([error, cleanupError], 'Camera effect update rejection cleanup failed'); + } + } + pending.reject(rejection); + } + + beginStop(): void { + if (this.stopIntent) { + return; + } + this.stopIntent = true; + const failures: Array = []; + const operations = [ + () => { + if (this.selectedTrack != null) { + this.selectedTrack.removeEventListener('ended', this.handleOutputTrackEnded); + } + }, + () => this.worker.postMessage({kind: WebCameraEffectCommandKind.STOP}), + ]; + for (const operation of operations) { + try { + operation(); + } catch (error) { + failures.push(error); + } + } + throwCollectedFailures({failures, message: 'Camera effect pipeline stop intent failed'}); + } + + stop(): void { + if (this.stopped) { + return; + } + if (!this.initialized) { + this.failInitialization(new Error('Camera effect pipeline stopped during initialization')); + return; + } + this.stopped = true; + const failures: Array = []; + const operations = [ + () => this.beginStop(), + () => this.rejectPendingUpdate(new Error('Camera effect pipeline stopped during configuration update')), + () => { + this.currentVideoProducer?.stop(); + this.currentVideoProducer = null; + }, + () => this.gpuTrack.stop(), + () => this.fallbackTrack.stop(), + () => { + this.shutdownTimeout = window.setTimeout(() => { + try { + this.terminateWorker(); + } catch (error) { + logger.error('voice.camera_effect_worker_termination.failed', { + errorType: getErrorDiagnosticType(error), + }); + } + }, SHUTDOWN_TIMEOUT_MS); + }, + ]; + for (const operation of operations) { + try { + operation(); + } catch (error) { + failures.push(error); + } + } + throwCollectedFailures({failures, message: 'Camera effect pipeline shutdown failed'}); + } + + private readonly handleWorkerMessage = (event: MessageEvent): void => { + let message: WebCameraEffectWorkerEvent; + try { + message = parseCameraEffectWorkerEvent(event.data); + } catch (error) { + const eventError = new Error('Camera effect worker emitted an invalid event', {cause: error}); + if (!this.initialized) { + this.failInitialization(eventError); + return; + } + logger.error('voice.camera_effect_worker_event_invalid.rejected', {errorType: getErrorDiagnosticType(error)}); + this.stopAfterWorkerFailure(eventError); + return; + } + if (message.kind === WebCameraEffectEventKind.READY) { + this.completeInitialization(message); + return; + } + if (message.kind === WebCameraEffectEventKind.UPDATED) { + this.completeUpdate(message.requestId); + return; + } + if (message.kind === WebCameraEffectEventKind.UPDATE_FAILED) { + this.rejectUpdate(message); + return; + } + if (message.kind === WebCameraEffectEventKind.FAILED) { + const error = createWorkerReportedError(message, 'Camera effect worker failed'); + if (!this.initialized) { + this.failInitialization(error); + return; + } + if (this.stopped) { + return; + } + this.rejectPendingUpdate(error); + if (this.stopIntent) { + logger.debug('voice.camera_effect_worker_failed_after_stop_intent.ignored', { + errorType: message.errorType, + }); + this.stop(); + return; + } + logger.error('voice.camera_effect_worker.failed', { + errorType: message.errorType, + message: message.message, + stack: message.stack, + }); + this.stopAfterWorkerFailure(error); + return; + } + if (!this.initialized) { + this.failInitialization(new Error('Camera effect worker stopped during initialization')); + return; + } + if (message.reason === WebCameraEffectShutdownReason.CLEANUP_FAILED) { + logger.warn('voice.camera_effect_worker_cleanup.incomplete', { + errorType: message.diagnostic?.errorType ?? null, + message: message.diagnostic?.message ?? null, + stack: message.diagnostic?.stack ?? null, + }); + } else if (message.reason !== WebCameraEffectShutdownReason.OWNER_STOP && !this.stopIntent && !this.stopped) { + const error = new Error('Camera effect worker stopped unexpectedly'); + logger.error('voice.camera_effect_worker_stopped_unexpectedly.failed', {reason: message.reason}); + this.stopAfterWorkerFailure(error); + return; + } + try { + this.terminateWorker(); + } catch (error) { + logger.error('voice.camera_effect_worker_termination.failed', { + errorType: getErrorDiagnosticType(error), + }); + } + }; + + private readonly handleWorkerError = (event: ErrorEvent): void => { + const location = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : ''; + const detail = event.message ? `: ${event.message}${location}` : location; + const error = new Error(`Camera effect worker raised an error${detail}`, { + cause: event.error ?? undefined, + }); + if (!this.initialized) { + this.failInitialization(error); + return; + } + if (!this.stopped) { + logger.error('voice.camera_effect_worker.failed', { + errorType: getErrorDiagnosticType(event.error), + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + this.stopAfterWorkerFailure(error); + } + }; + + private readonly handleWorkerMessageError = (): void => { + const error = new Error('Camera effect worker message could not be deserialized'); + if (!this.initialized) { + this.failInitialization(error); + return; + } + if (!this.stopped) { + logger.error('voice.camera_effect_worker_message.failed'); + this.stopAfterWorkerFailure(error); + } + }; + + private readonly handleOutputTrackEnded = (): void => { + if (!this.initialized || this.stopped) { + return; + } + this.stopAfterWorkerFailure(new Error('Camera effect output track ended unexpectedly')); + }; + + private completeInitialization(result: InitializationResult): void { + if (this.initialized) { + logger.error('voice.camera_effect_worker_sent_more_than_one_ready_event.failed'); + this.stopAfterWorkerFailure(new Error('Camera effect worker sent more than one ready event')); + return; + } + if (this.stopped) { + return; + } + recordWebGPUSelection(this.preferWebGPU, result.backend); + let selectedTrack = this.fallbackTrack; + let unusedTrack = this.gpuTrack; + if (result.backend === WebCameraEffectBackend.WEB_GPU) { + selectedTrack = this.gpuTrack; + unusedTrack = this.fallbackTrack; + } + try { + unusedTrack.stop(); + selectedTrack.addEventListener('ended', this.handleOutputTrackEnded, {once: true}); + } catch (error) { + this.failInitialization( + new Error('Camera effect pipeline could not release its unused output track', {cause: error}), + ); + return; + } + this.initialized = true; + this.selectedBackend = result.backend; + this.selectedTrack = selectedTrack; + this.clearInitializationTimeout(); + this.resolveInitialization(result); + } + + private stopAfterWorkerFailure(error: Error): void { + if (this.operationalFailure != null || this.stopped) { + return; + } + this.operationalFailure = error; + const failures: Array = []; + try { + this.onFailure(this, error); + } catch (callbackError) { + failures.push(callbackError); + } + try { + this.stop(); + } catch (cleanupError) { + failures.push(cleanupError); + } + if (failures.length > 0) { + const failure = new AggregateError( + [error, ...failures], + 'Camera effect worker failure notification or cleanup failed', + ); + logger.error('voice.camera_effect_worker_failure_cleanup.failed', { + errorType: getErrorDiagnosticType(failure), + }); + } + } + + private failInitialization(error: Error): void { + if (this.initialized || this.stopped) { + return; + } + this.stopped = true; + this.clearInitializationTimeout(); + releaseWebGPUProbe(this.preferWebGPU); + const failures: Array = [error]; + const cleanup = [ + () => { + this.currentVideoProducer?.stop(); + this.currentVideoProducer = null; + }, + () => this.gpuTrack.stop(), + () => this.fallbackTrack.stop(), + () => this.terminateWorker(), + ]; + for (const operation of cleanup) { + try { + operation(); + } catch (cleanupError) { + failures.push(cleanupError); + } + } + if (failures.length === 1) { + this.rejectInitialization(error); + return; + } + this.rejectInitialization( + new AggregateError(failures, 'Camera effect pipeline initialization failed during cleanup'), + ); + } + + private clearInitializationTimeout(): void { + if (this.initializationTimeout != null) { + window.clearTimeout(this.initializationTimeout); + this.initializationTimeout = null; + } + } + + private terminateWorker(): void { + if (this.shutdownTimeout != null) { + window.clearTimeout(this.shutdownTimeout); + this.shutdownTimeout = null; + } + const failures: Array = []; + const cleanup = [ + () => this.worker.removeEventListener('message', this.handleWorkerMessage), + () => this.worker.removeEventListener('error', this.handleWorkerError), + () => this.worker.removeEventListener('messageerror', this.handleWorkerMessageError), + () => this.worker.terminate(), + ]; + for (const operation of cleanup) { + try { + operation(); + } catch (error) { + failures.push(error); + } + } + throwCollectedFailures({failures, message: 'Camera effect worker termination failed'}); + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectProtocol.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectProtocol.ts new file mode 100644 index 000000000..6ff4c77a2 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectProtocol.ts @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {CameraBackgroundMode} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import type {ErrorDiagnostic, ErrorDiagnosticType} from '@app/features/voice/utils/camera-effects/ErrorDiagnostic'; + +class InvalidCameraEffectFrameDimensionsError extends Error { + constructor() { + super('Camera effect frame dimensions must be positive safe integers'); + this.name = 'InvalidCameraEffectFrameDimensionsError'; + } +} + +class CameraEffectFrameDimensionsExceededError extends Error { + constructor() { + super('Camera effect frame dimensions exceed the bounded working set'); + this.name = 'CameraEffectFrameDimensionsExceededError'; + } +} + +export interface WebCameraEffectBlurConfig { + readonly mode: typeof CameraBackgroundMode.BLUR; + readonly blurStrength: number; +} + +export interface WebCameraEffectCustomConfig { + readonly mode: typeof CameraBackgroundMode.CUSTOM; + readonly blurStrength: number; + readonly customMediaURL: string; + readonly customMediaKind: WebCameraEffectCustomMediaKind; +} + +export type WebCameraEffectConfig = WebCameraEffectBlurConfig | WebCameraEffectCustomConfig; + +export interface WebCameraPipelineConfig { + readonly background: WebCameraEffectConfig | null; +} + +export const WEB_CAMERA_EFFECT_SEGMENTATION_MIN_INTERVAL_MS = 50; + +export const WEB_CAMERA_EFFECT_STOP_GRACE_MS = 250; + +const MIN_BLUR_PIXELS = 4; +const MAX_BLUR_PIXELS = 20; +const WEB_CAMERA_EFFECT_BLUR_STRENGTH_MIN = 0; +const WEB_CAMERA_EFFECT_BLUR_STRENGTH_MAX = 100; +const MAX_FRAME_EDGE = 4096; +const MAX_FRAME_PIXELS = 3840 * 2160; + +export function requireCameraEffectBlurStrength(strength: number): number { + if (!Number.isFinite(strength)) { + throw new Error('Camera effect blur strength must be finite'); + } + if (strength < WEB_CAMERA_EFFECT_BLUR_STRENGTH_MIN) { + throw new Error('Camera effect blur strength is below the supported range'); + } + if (strength > WEB_CAMERA_EFFECT_BLUR_STRENGTH_MAX) { + throw new Error('Camera effect blur strength exceeds the supported range'); + } + return strength; +} + +export function cameraEffectBlurPixels(strength: number): number { + const validatedStrength = requireCameraEffectBlurStrength(strength); + const range = MAX_BLUR_PIXELS - MIN_BLUR_PIXELS; + return Math.round(MIN_BLUR_PIXELS + (validatedStrength / WEB_CAMERA_EFFECT_BLUR_STRENGTH_MAX) * range); +} + +export function validateCameraEffectFrameDimensions(width: number, height: number): void { + if (!Number.isSafeInteger(width)) { + throw new InvalidCameraEffectFrameDimensionsError(); + } + if (width <= 0) { + throw new InvalidCameraEffectFrameDimensionsError(); + } + if (!Number.isSafeInteger(height)) { + throw new InvalidCameraEffectFrameDimensionsError(); + } + if (height <= 0) { + throw new InvalidCameraEffectFrameDimensionsError(); + } + if (width > MAX_FRAME_EDGE) { + throw new CameraEffectFrameDimensionsExceededError(); + } + if (height > MAX_FRAME_EDGE) { + throw new CameraEffectFrameDimensionsExceededError(); + } + if (width * height > MAX_FRAME_PIXELS) { + throw new CameraEffectFrameDimensionsExceededError(); + } +} + +export const WebCameraEffectBackend = Object.freeze({ + WEB_GPU: 'webgpu', + WASM_WORKER: 'wasm-worker', + CANVAS_WORKER: 'canvas-worker', +} as const); + +export type WebCameraEffectBackend = (typeof WebCameraEffectBackend)[keyof typeof WebCameraEffectBackend]; + +export const WebCameraEffectCommandKind = Object.freeze({ + START: 'start', + UPDATE: 'update', + STOP: 'stop', +} as const); + +export type WebCameraEffectCommandKind = (typeof WebCameraEffectCommandKind)[keyof typeof WebCameraEffectCommandKind]; + +export const WebCameraEffectEventKind = Object.freeze({ + READY: 'ready', + UPDATED: 'updated', + UPDATE_FAILED: 'update_failed', + FAILED: 'failed', + STOPPED: 'stopped', +} as const); + +export type WebCameraEffectEventKind = (typeof WebCameraEffectEventKind)[keyof typeof WebCameraEffectEventKind]; + +export const WebCameraEffectShutdownReason = Object.freeze({ + OWNER_STOP: 'owner-stop', + INPUT_ENDED: 'input-ended', + OPERATION_FAILED: 'operation-failed', + CLEANUP_FAILED: 'cleanup-failed', +} as const); + +export type WebCameraEffectShutdownReason = + (typeof WebCameraEffectShutdownReason)[keyof typeof WebCameraEffectShutdownReason]; + +export function isWebCameraEffectShutdownReason(value: unknown): value is WebCameraEffectShutdownReason { + if (value === WebCameraEffectShutdownReason.OWNER_STOP) { + return true; + } + if (value === WebCameraEffectShutdownReason.INPUT_ENDED) { + return true; + } + if (value === WebCameraEffectShutdownReason.OPERATION_FAILED) { + return true; + } + return value === WebCameraEffectShutdownReason.CLEANUP_FAILED; +} + +export const WebCameraEffectCustomMediaKind = Object.freeze({ + STATIC: 'static', + ANIMATED: 'animated', + VIDEO: 'video', +} as const); + +export type WebCameraEffectCustomMediaKind = + (typeof WebCameraEffectCustomMediaKind)[keyof typeof WebCameraEffectCustomMediaKind]; + +export function isWebCameraEffectCustomMediaKind(value: unknown): value is WebCameraEffectCustomMediaKind { + if (value === WebCameraEffectCustomMediaKind.STATIC) { + return true; + } + if (value === WebCameraEffectCustomMediaKind.ANIMATED) { + return true; + } + return value === WebCameraEffectCustomMediaKind.VIDEO; +} + +export const WEB_CAMERA_EFFECT_UPDATE_REQUEST_ID_MAX = 2_147_483_647; + +function isWebCameraPipelineConfig(value: unknown): value is WebCameraPipelineConfig { + if (value == null || typeof value !== 'object') { + return false; + } + const background = Reflect.get(value, 'background'); + if (background === null) { + return true; + } + if (background == null || typeof background !== 'object') { + return false; + } + const mode = Reflect.get(background, 'mode'); + const blurStrength = Reflect.get(background, 'blurStrength'); + if (typeof blurStrength !== 'number') { + return false; + } + try { + requireCameraEffectBlurStrength(blurStrength); + } catch { + return false; + } + if (mode === CameraBackgroundMode.BLUR) { + return true; + } + if (mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + const customMediaURL = Reflect.get(background, 'customMediaURL'); + if (typeof customMediaURL !== 'string' || customMediaURL.length === 0) { + return false; + } + return isWebCameraEffectCustomMediaKind(Reflect.get(background, 'customMediaKind')); +} + +function hasExpectedCustomBackgroundFrames( + config: WebCameraPipelineConfig, + frames: unknown, + requireVideoFrames: boolean, +): frames is ReadableStream | null { + const background = config.background; + if (background == null || background.mode !== CameraBackgroundMode.CUSTOM) { + return frames === null; + } + if (background.customMediaKind !== WebCameraEffectCustomMediaKind.VIDEO) { + return frames === null; + } + if (!requireVideoFrames && frames === null) { + return true; + } + return frames instanceof ReadableStream; +} + +function isValidStartCommand(value: object): value is WebCameraEffectStartCommand { + if (typeof Reflect.get(value, 'preferWebGPU') !== 'boolean') { + return false; + } + const config = Reflect.get(value, 'config'); + if (!isWebCameraPipelineConfig(config)) { + return false; + } + if (!(Reflect.get(value, 'readable') instanceof ReadableStream)) { + return false; + } + if (!(Reflect.get(value, 'gpuCanvas') instanceof OffscreenCanvas)) { + return false; + } + if (!(Reflect.get(value, 'fallbackCanvas') instanceof OffscreenCanvas)) { + return false; + } + return hasExpectedCustomBackgroundFrames(config, Reflect.get(value, 'customBackgroundFrames'), true); +} + +function isValidUpdateRequestId(value: unknown): value is number { + if (!Number.isSafeInteger(value)) { + return false; + } + if ((value as number) <= 0) { + return false; + } + return (value as number) <= WEB_CAMERA_EFFECT_UPDATE_REQUEST_ID_MAX; +} + +function isValidUpdateCommand(value: object): value is WebCameraEffectUpdateCommand { + if (!isValidUpdateRequestId(Reflect.get(value, 'requestId'))) { + return false; + } + const config = Reflect.get(value, 'config'); + if (!isWebCameraPipelineConfig(config)) { + return false; + } + return hasExpectedCustomBackgroundFrames(config, Reflect.get(value, 'customBackgroundFrames'), false); +} + +export interface WebCameraEffectStartCommand { + readonly kind: typeof WebCameraEffectCommandKind.START; + readonly readable: ReadableStream; + readonly customBackgroundFrames: ReadableStream | null; + readonly gpuCanvas: OffscreenCanvas; + readonly fallbackCanvas: OffscreenCanvas; + readonly config: WebCameraPipelineConfig; + readonly preferWebGPU: boolean; +} + +export interface WebCameraEffectUpdateCommand { + readonly kind: typeof WebCameraEffectCommandKind.UPDATE; + readonly requestId: number; + readonly config: WebCameraPipelineConfig; + readonly customBackgroundFrames: ReadableStream | null; +} + +export interface WebCameraEffectStopCommand { + readonly kind: typeof WebCameraEffectCommandKind.STOP; +} + +export type WebCameraEffectWorkerCommand = + | WebCameraEffectStartCommand + | WebCameraEffectUpdateCommand + | WebCameraEffectStopCommand; + +export const WebCameraEffectCommandPolicy = Object.freeze({ + isValid(value: object): value is WebCameraEffectWorkerCommand { + switch (Reflect.get(value, 'kind')) { + case WebCameraEffectCommandKind.START: + return isValidStartCommand(value); + case WebCameraEffectCommandKind.UPDATE: + return isValidUpdateCommand(value); + case WebCameraEffectCommandKind.STOP: + return true; + default: + return false; + } + }, +}); + +export interface WebCameraEffectReadyEvent { + readonly kind: typeof WebCameraEffectEventKind.READY; + readonly backend: WebCameraEffectBackend; + readonly fallbackErrorType: ErrorDiagnosticType | null; +} + +export interface WebCameraEffectUpdatedEvent { + readonly kind: typeof WebCameraEffectEventKind.UPDATED; + readonly requestId: number; +} + +export interface WebCameraEffectUpdateFailedEvent extends ErrorDiagnostic { + readonly kind: typeof WebCameraEffectEventKind.UPDATE_FAILED; + readonly requestId: number; +} + +export interface WebCameraEffectFailedEvent extends ErrorDiagnostic { + readonly kind: typeof WebCameraEffectEventKind.FAILED; +} + +export interface WebCameraEffectStoppedEvent { + readonly kind: typeof WebCameraEffectEventKind.STOPPED; + readonly reason: WebCameraEffectShutdownReason; + readonly diagnostic: ErrorDiagnostic | null; +} + +export type WebCameraEffectWorkerEvent = + | WebCameraEffectReadyEvent + | WebCameraEffectUpdatedEvent + | WebCameraEffectUpdateFailedEvent + | WebCameraEffectFailedEvent + | WebCameraEffectStoppedEvent; diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectRenderer.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectRenderer.ts new file mode 100644 index 000000000..929714d4d --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectRenderer.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {WebCameraEffectCustomFrameSource} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomImage'; +import type { + WebCameraEffectBackend, + WebCameraPipelineConfig, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; + +export interface WebCameraEffectRenderer { + readonly backend: WebCameraEffectBackend; + configure(config: WebCameraPipelineConfig, customFrameSource: WebCameraEffectCustomFrameSource | null): Promise; + render(frame: VideoFrame, now: number): Promise; + dispose(): Promise; +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectSegmentationOwner.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectSegmentationOwner.ts new file mode 100644 index 000000000..71fee2531 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectSegmentationOwner.ts @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import invariant from 'tiny-invariant'; + +const WEB_CAMERA_EFFECT_OUTPUT_FRAMES_BEHIND_SEGMENTATION_MAX = 2; + +interface MutableWebCameraEffectSegmentationPhysicalOperation { + readonly lifecycle: number; + admittedOutputFrames: number; + settlement: Promise; +} + +export class WebCameraEffectSegmentationOwner { + private lifecycle = 0; + private physicalOperation: MutableWebCameraEffectSegmentationPhysicalOperation | null = null; + private deferredFailure: unknown | null = null; + + canStartPhysicalOperation(): boolean { + return this.physicalOperation == null; + } + + advanceLifecycle(): void { + const nextLifecycle = this.lifecycle + 1; + invariant(Number.isSafeInteger(nextLifecycle), 'WebGPU camera segmentation lifecycle must stay safe'); + this.lifecycle = nextLifecycle; + } + + startPhysicalOperation(operation: Promise, publishCurrentCompletion: () => void): void { + invariant(this.physicalOperation == null, 'WebGPU camera segmentation physical operation overlapped'); + invariant(this.deferredFailure == null, 'WebGPU camera segmentation failure must be observed before restart'); + const physicalOperation: MutableWebCameraEffectSegmentationPhysicalOperation = { + lifecycle: this.lifecycle, + admittedOutputFrames: 0, + settlement: Promise.resolve(), + }; + this.physicalOperation = physicalOperation; + physicalOperation.settlement = operation.then( + () => this.completeSuccessfulPhysicalOperation(physicalOperation, publishCurrentCompletion), + (error: unknown) => this.completeFailedPhysicalOperation(physicalOperation, error), + ); + } + + admitOutputFrame(): Promise | null { + const physicalOperation = this.physicalOperation; + if (physicalOperation == null) { + return null; + } + if (physicalOperation.admittedOutputFrames < WEB_CAMERA_EFFECT_OUTPUT_FRAMES_BEHIND_SEGMENTATION_MAX) { + physicalOperation.admittedOutputFrames += 1; + return null; + } + return this.waitForOutputFrameAdmission(physicalOperation); + } + + private async waitForOutputFrameAdmission( + physicalOperation: MutableWebCameraEffectSegmentationPhysicalOperation, + ): Promise { + await physicalOperation.settlement; + this.requireNoDeferredFailure(); + } + + async settlePhysicalOperation(): Promise { + const physicalOperation = this.physicalOperation; + if (physicalOperation != null) { + await physicalOperation.settlement; + } + this.requireNoDeferredFailure(); + } + + async settleForDisposal(): Promise> { + const failures: Array = []; + const physicalOperation = this.physicalOperation; + if (physicalOperation != null) { + try { + await physicalOperation.settlement; + } catch (error) { + failures.push(error); + } + } + const failure = this.deferredFailure; + this.deferredFailure = null; + if (failure != null) { + failures.push(failure); + } + return failures; + } + + requireNoDeferredFailure(): void { + const failure = this.deferredFailure; + if (failure == null) { + return; + } + this.deferredFailure = null; + throw failure; + } + + private completeSuccessfulPhysicalOperation( + operation: MutableWebCameraEffectSegmentationPhysicalOperation, + publishCurrentCompletion: () => void, + ): void { + invariant(this.physicalOperation === operation, 'WebGPU camera segmentation physical ownership changed'); + this.physicalOperation = null; + if (operation.lifecycle !== this.lifecycle) { + return; + } + try { + publishCurrentCompletion(); + } catch (error) { + this.recordDeferredFailure(error); + } + } + + private completeFailedPhysicalOperation( + operation: MutableWebCameraEffectSegmentationPhysicalOperation, + failure: unknown, + ): void { + invariant(this.physicalOperation === operation, 'WebGPU camera segmentation physical ownership changed'); + this.physicalOperation = null; + this.recordDeferredFailure(failure); + } + + private recordDeferredFailure(failure: unknown): void { + let normalizedFailure = failure; + if (normalizedFailure == null) { + normalizedFailure = new Error('WebGPU camera segmentation failed without an error value'); + } + invariant(this.deferredFailure == null, 'WebGPU camera segmentation owner retained multiple failures'); + this.deferredFailure = normalizedFailure; + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectVideoFrameSource.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectVideoFrameSource.ts new file mode 100644 index 000000000..dde4355c9 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectVideoFrameSource.ts @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {runWithResponseDeadline} from '@app/features/voice/utils/camera-effects/BoundedResponse'; +import { + readWebCameraEffectCustomMediaBlob, + requireWebCameraEffectCustomMediaURL, + WEB_CAMERA_EFFECT_CUSTOM_MEDIA_OPERATION_TIMEOUT_MS, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomImage'; +import {validateCameraEffectFrameDimensions} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; + +const MAX_CUSTOM_VIDEO_DURATION_SECONDS = 60; +const SUPPORTED_VIDEO_MEDIA_TYPES = new Set(['video/mp4', 'video/webm']); +const VIDEO_HEADER_SNIFF_BYTES = 128; +const MP4_FILE_TYPE_BOX_OFFSET = 4; +const MP4_FILE_TYPE_BOX: ReadonlyArray = [0x66, 0x74, 0x79, 0x70]; +const EBML_MAGIC: ReadonlyArray = [0x1a, 0x45, 0xdf, 0xa3]; +const EBML_DOC_TYPE_ELEMENT_ID: ReadonlyArray = [0x42, 0x82]; +const EBML_VINT_SINGLE_BYTE_MARKER = 0x80; +const EBML_VINT_SINGLE_BYTE_VALUE_MASK = 0x7f; +const MAX_EBML_DOC_TYPE_LENGTH = 16; +const WEBM_DOC_TYPE = 'webm'; + +export interface WebCameraEffectVideoFrameProducer { + readonly readable: ReadableStream; + readonly width: number; + readonly height: number; + stop(): void; +} + +class WebCameraEffectVideoFrameProducerOwner implements WebCameraEffectVideoFrameProducer { + readonly readable: ReadableStream; + private readonly video: HTMLVideoElement; + private readonly objectURL: string; + private controller: ReadableStreamDefaultController | null = null; + private pendingFrame: VideoFrame | null = null; + private pullResolve: (() => void) | null = null; + private callbackHandle: number | null = null; + private started = false; + private stopped = false; + private videoWidth = 0; + private videoHeight = 0; + + constructor(blob: Blob) { + this.video = document.createElement('video'); + this.video.autoplay = true; + this.video.crossOrigin = 'anonymous'; + this.video.loop = true; + this.video.muted = true; + this.video.playsInline = true; + this.video.preload = 'auto'; + this.readable = new ReadableStream( + { + start: (controller): void => { + this.controller = controller; + }, + pull: (controller) => this.pullFrame(controller), + cancel: () => this.cancelFromStream(), + }, + {highWaterMark: 0}, + ); + this.objectURL = URL.createObjectURL(blob); + } + + get width(): number { + return this.videoWidth; + } + + get height(): number { + return this.videoHeight; + } + + async start(signal: AbortSignal): Promise { + if (this.started) { + throw new Error('Custom camera background video producer was started more than once'); + } + this.started = true; + if (typeof this.video.requestVideoFrameCallback !== 'function') { + throw new Error('Custom camera background video requires requestVideoFrameCallback'); + } + if (!('VideoFrame' in globalThis)) { + throw new Error('Custom camera background video requires VideoFrame'); + } + this.video.src = this.objectURL; + this.video.load(); + await waitForVideoMetadata(this.video, signal); + this.validateMetadata(); + this.video.addEventListener('error', this.handlePlaybackError); + this.video.addEventListener('ended', this.handleUnexpectedEnd); + await waitForAbortablePromise(this.video.play(), signal); + throwIfAborted(signal); + this.scheduleNextFrame(); + } + + stop(): void { + const failures = this.releaseOwnedResources(true); + throwCleanupFailures(failures, 'Custom camera background video producer teardown failed'); + } + + private pullFrame(controller: ReadableStreamDefaultController): void | Promise { + if (this.stopped) return; + if (this.pendingFrame != null) { + const frame = this.pendingFrame; + this.pendingFrame = null; + controller.enqueue(frame); + return; + } + if (this.pullResolve != null) { + throw new Error('Custom camera background video stream has more than one pending pull'); + } + return new Promise((resolve) => { + this.pullResolve = resolve; + }); + } + + private cancelFromStream(): void { + const failures = this.releaseOwnedResources(false); + throwCleanupFailures(failures, 'Custom camera background video stream cancellation failed'); + } + + private validateMetadata(): void { + validateCameraEffectFrameDimensions(this.video.videoWidth, this.video.videoHeight); + if (!Number.isFinite(this.video.duration) || this.video.duration <= 0) { + throw new Error('Custom camera background video has an invalid duration'); + } + if (this.video.duration > MAX_CUSTOM_VIDEO_DURATION_SECONDS) { + throw new Error('Custom camera background video exceeds the supported duration'); + } + this.videoWidth = this.video.videoWidth; + this.videoHeight = this.video.videoHeight; + } + + private scheduleNextFrame(): void { + if (this.stopped) return; + try { + this.callbackHandle = this.video.requestVideoFrameCallback(this.handleVideoFrame); + } catch (error) { + this.fail(error); + } + } + + private readonly handleVideoFrame: VideoFrameRequestCallback = (_now, metadata): void => { + this.callbackHandle = null; + if (this.stopped) return; + let frame: VideoFrame | null = null; + try { + if (!Number.isFinite(metadata.mediaTime) || metadata.mediaTime < 0) { + throw new Error('Custom camera background video produced an invalid frame timestamp'); + } + const timestamp = Math.round(metadata.mediaTime * 1_000_000); + if (!Number.isSafeInteger(timestamp)) { + throw new Error('Custom camera background video frame timestamp exceeds the safe range'); + } + frame = new VideoFrame(this.video, {timestamp}); + validateCameraEffectFrameDimensions(frame.displayWidth, frame.displayHeight); + this.publishFrame(frame); + frame = null; + } catch (error) { + frame?.close(); + this.fail(error); + return; + } + this.scheduleNextFrame(); + }; + + private publishFrame(frame: VideoFrame): void { + const controller = this.controller; + if (controller == null) { + throw new Error('Custom camera background video stream has no controller'); + } + if (this.pullResolve != null) { + const resolve = this.pullResolve; + this.pullResolve = null; + controller.enqueue(frame); + resolve(); + return; + } + const replaced = this.pendingFrame; + this.pendingFrame = frame; + replaced?.close(); + } + + private readonly handlePlaybackError = (): void => { + this.fail(videoElementError(this.video)); + }; + + private readonly handleUnexpectedEnd = (): void => { + this.fail(new Error('Custom camera background video playback ended unexpectedly')); + }; + + private fail(error: unknown): void { + if (this.stopped) return; + const failures = this.releaseOwnedResources(false); + const reportedError = + failures.length === 0 + ? error + : new AggregateError([error, ...failures], 'Custom camera background video failed during cleanup'); + this.controller?.error(reportedError); + } + + private releaseOwnedResources(closeStream: boolean): Array { + if (this.stopped) return []; + this.stopped = true; + const failures: Array = []; + this.cancelFrameCallback(failures); + this.closePendingFrame(failures); + this.resolvePendingPull(); + this.releaseVideoElement(failures); + if (closeStream) { + try { + this.controller?.close(); + } catch (error) { + failures.push(error); + } + } + return failures; + } + + private cancelFrameCallback(failures: Array): void { + if (this.callbackHandle == null) return; + try { + this.video.cancelVideoFrameCallback(this.callbackHandle); + } catch (error) { + failures.push(error); + } + this.callbackHandle = null; + } + + private closePendingFrame(failures: Array): void { + const pendingFrame = this.pendingFrame; + this.pendingFrame = null; + if (pendingFrame == null) return; + try { + pendingFrame.close(); + } catch (error) { + failures.push(error); + } + } + + private resolvePendingPull(): void { + const resolve = this.pullResolve; + this.pullResolve = null; + resolve?.(); + } + + private releaseVideoElement(failures: Array): void { + this.video.removeEventListener('error', this.handlePlaybackError); + this.video.removeEventListener('ended', this.handleUnexpectedEnd); + try { + this.video.pause(); + this.video.removeAttribute('src'); + this.video.load(); + } catch (error) { + failures.push(error); + } + try { + URL.revokeObjectURL(this.objectURL); + } catch (error) { + failures.push(error); + } + } +} + +function normalizedMediaType(value: string): string { + return value.split(';', 1)[0]?.trim().toLowerCase() ?? ''; +} + +function bytesEqualAt(bytes: Uint8Array, offset: number, expected: ReadonlyArray): boolean { + if (bytes.byteLength < offset + expected.length) return false; + for (let index = 0; index < expected.length; index += 1) { + if (bytes[offset + index] !== expected[index]) return false; + } + return true; +} + +function isEBMLDocTypeByte(byte: number): boolean { + if (byte >= 0x30 && byte <= 0x39) return true; + if (byte >= 0x41 && byte <= 0x5a) return true; + if (byte >= 0x61 && byte <= 0x7a) return true; + return byte === 0x2d || byte === 0x5f; +} + +function readEBMLDocTypeValue(bytes: Uint8Array, start: number, length: number): string | null { + let docType = ''; + for (let index = 0; index < length; index += 1) { + const byte = bytes[start + index] ?? 0; + if (byte === 0) break; + if (!isEBMLDocTypeByte(byte)) return null; + docType += String.fromCharCode(byte); + } + if (docType.length === 0) return null; + return docType; +} + +function readEBMLDocType(bytes: Uint8Array): string | null { + const searchLimit = bytes.byteLength - EBML_DOC_TYPE_ELEMENT_ID.length; + for (let offset = EBML_MAGIC.length; offset < searchLimit; offset += 1) { + if (!bytesEqualAt(bytes, offset, EBML_DOC_TYPE_ELEMENT_ID)) continue; + const sizeOffset = offset + EBML_DOC_TYPE_ELEMENT_ID.length; + const sizeByte = bytes[sizeOffset] ?? 0; + if ((sizeByte & EBML_VINT_SINGLE_BYTE_MARKER) === 0) continue; + const length = sizeByte & EBML_VINT_SINGLE_BYTE_VALUE_MASK; + if (length === 0 || length > MAX_EBML_DOC_TYPE_LENGTH) continue; + const valueOffset = sizeOffset + 1; + if (valueOffset + length > bytes.byteLength) return null; + return readEBMLDocTypeValue(bytes, valueOffset, length); + } + return null; +} + +function sniffVideoMediaType(bytes: Uint8Array): string | null { + if (bytesEqualAt(bytes, MP4_FILE_TYPE_BOX_OFFSET, MP4_FILE_TYPE_BOX)) return 'video/mp4'; + if (!bytesEqualAt(bytes, 0, EBML_MAGIC)) return null; + const docType = readEBMLDocType(bytes); + if (docType == null) { + throw new Error('Custom camera background video has an unreadable EBML document type'); + } + if (docType !== WEBM_DOC_TYPE) { + throw new Error(`Custom camera background video document type is unsupported: ${docType}`); + } + return 'video/webm'; +} + +async function normalizeCustomVideoBlob(blob: Blob, signal: AbortSignal): Promise { + const header = new Uint8Array(await blob.slice(0, VIDEO_HEADER_SNIFF_BYTES).arrayBuffer()); + throwIfAborted(signal); + const detectedMediaType = sniffVideoMediaType(header); + if (detectedMediaType == null) { + throw new Error('Custom camera background is not a supported video format'); + } + const declaredMediaType = normalizedMediaType(blob.type); + if (SUPPORTED_VIDEO_MEDIA_TYPES.has(declaredMediaType) && declaredMediaType !== detectedMediaType) { + throw new Error('Custom camera background video type does not match its encoded data'); + } + if (declaredMediaType === detectedMediaType) return blob; + return new Blob([blob], {type: detectedMediaType}); +} + +function videoElementError(video: HTMLVideoElement): Error { + const code = video.error?.code ?? 0; + const message = video.error?.message.trim() ?? ''; + if (message.length > 0) return new Error(`Custom camera background video failed (${code}): ${message}`); + return new Error(`Custom camera background video failed with media error ${code}`); +} + +function waitForVideoMetadata(video: HTMLVideoElement, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + if (video.readyState >= 1 && video.videoWidth > 0 && video.videoHeight > 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const cleanup = (): void => { + video.removeEventListener('loadedmetadata', handleMetadata); + video.removeEventListener('error', handleError); + signal.removeEventListener('abort', handleAbort); + }; + const handleMetadata = (): void => { + cleanup(); + resolve(); + }; + const handleError = (): void => { + cleanup(); + reject(videoElementError(video)); + }; + const handleAbort = (): void => { + cleanup(); + reject(signal.reason); + }; + video.addEventListener('loadedmetadata', handleMetadata, {once: true}); + video.addEventListener('error', handleError, {once: true}); + signal.addEventListener('abort', handleAbort, {once: true}); + }); +} + +function waitForAbortablePromise(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const handleAbort = (): void => reject(signal.reason); + signal.addEventListener('abort', handleAbort, {once: true}); + promise.then( + (value) => { + signal.removeEventListener('abort', handleAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', handleAbort); + reject(error); + }, + ); + }); +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason ?? new Error('Custom camera background video operation was aborted'); +} + +function throwCleanupFailures(failures: ReadonlyArray, message: string): void { + if (failures.length === 0) return; + if (failures.length === 1) throw failures[0]; + throw new AggregateError(failures, message); +} + +export async function createWebCameraEffectVideoFrameProducer( + mediaURL: string, +): Promise { + requireWebCameraEffectCustomMediaURL(mediaURL); + return runWithResponseDeadline({ + timeoutMilliseconds: WEB_CAMERA_EFFECT_CUSTOM_MEDIA_OPERATION_TIMEOUT_MS, + description: 'Custom camera background video initialization', + signal: null, + operation: async (signal) => { + const blob = await readWebCameraEffectCustomMediaBlob(mediaURL, signal); + const normalizedBlob = await normalizeCustomVideoBlob(blob, signal); + const producer = new WebCameraEffectVideoFrameProducerOwner(normalizedBlob); + try { + await producer.start(signal); + return producer; + } catch (error) { + try { + producer.stop(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Custom camera background video initialization failed during cleanup', + ); + } + throw error; + } + }, + }); +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGLMaskRefiner.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGLMaskRefiner.ts new file mode 100644 index 000000000..7db4c69b8 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGLMaskRefiner.ts @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + WEB_CAMERA_EFFECT_MASK_BAND_EPSILON, + WEB_CAMERA_EFFECT_MASK_EDGE_SOFTNESS, + WEB_CAMERA_EFFECT_MASK_GUIDE_RANGE_FALLOFF, + WEB_CAMERA_EFFECT_MASK_GUIDE_SPATIAL_FALLOFF, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectMask'; +import {SEG_INPUT_EDGE} from '@app/features/voice/utils/camera-effects/WebSelfieSegmenter'; + +const VERTEX_SHADER_SOURCE = `#version 300 es +precision highp float; + +out vec2 textureCoordinates; + +void main() { + vec2 positions[3] = vec2[3]( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0) + ); + vec2 position = positions[gl_VertexID]; + gl_Position = vec4(position, 0.0, 1.0); + textureCoordinates = vec2((position.x + 1.0) * 0.5, (1.0 - position.y) * 0.5); +} +`; + +const FRAGMENT_SHADER_SOURCE = `#version 300 es +precision highp float; + +uniform sampler2D sourceTexture; +uniform sampler2D maskTexture; +in vec2 textureCoordinates; +out vec4 outputColour; + +float luminance(vec3 colour) { + return dot(colour, vec3(0.2126, 0.7152, 0.0722)); +} + +float refinedMask(vec2 coordinates, float guide) { + vec2 maskSize = vec2(${SEG_INPUT_EDGE}.0); + vec2 maskPosition = coordinates * maskSize - vec2(0.5); + vec2 base = round(maskPosition); + float maskTotal = 0.0; + float weightTotal = 0.0; + for (int y = -1; y <= 1; y += 1) { + for (int x = -1; x <= 1; x += 1) { + vec2 offset = vec2(float(x), float(y)); + vec2 sampleCoordinates = clamp((base + offset + vec2(0.5)) / maskSize, vec2(0.0), vec2(1.0)); + float sampleGuide = luminance(textureLod(sourceTexture, sampleCoordinates, 0.0).rgb); + vec2 sampleDistance = maskPosition - (base + offset); + float spatialWeight = exp(-float(${WEB_CAMERA_EFFECT_MASK_GUIDE_SPATIAL_FALLOFF}) * dot(sampleDistance, sampleDistance)); + float rangeWeight = exp(-float(${WEB_CAMERA_EFFECT_MASK_GUIDE_RANGE_FALLOFF}) * abs(guide - sampleGuide)); + float weight = spatialWeight * rangeWeight; + maskTotal += textureLod(maskTexture, sampleCoordinates, 0.0).a * weight; + weightTotal += weight; + } + } + return maskTotal / max(weightTotal, 0.0001); +} + +void main() { + float guide = luminance(texture(sourceTexture, textureCoordinates).rgb); + float coarse = textureLod(maskTexture, textureCoordinates, 0.0).a; + float alpha = step(0.5, coarse); + if (coarse > ${WEB_CAMERA_EFFECT_MASK_BAND_EPSILON} && coarse < 1.0 - ${WEB_CAMERA_EFFECT_MASK_BAND_EPSILON}) { + float refined = refinedMask(textureCoordinates, guide); + float curve = clamp((refined - 0.5) / (2.0 * ${WEB_CAMERA_EFFECT_MASK_EDGE_SOFTNESS}) + 0.5, 0.0, 1.0); + alpha = curve * curve * (3.0 - 2.0 * curve); + } + outputColour = vec4(0.0, 0.0, 0.0, alpha); +} +`; + +function createShader(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader { + const shader = gl.createShader(type); + if (shader == null) { + throw new Error('Camera mask refinement could not allocate a WebGL shader'); + } + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) === true) { + return shader; + } + const diagnostic = gl.getShaderInfoLog(shader) ?? 'no shader diagnostic was provided'; + gl.deleteShader(shader); + throw new Error(`Camera mask refinement WebGL shader compilation failed: ${diagnostic}`); +} + +function createProgram(gl: WebGL2RenderingContext): WebGLProgram { + const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE); + let fragmentShader: WebGLShader | null = null; + let program: WebGLProgram | null = null; + try { + fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE); + program = gl.createProgram(); + if (program == null) { + throw new Error('Camera mask refinement could not allocate a WebGL program'); + } + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) { + const diagnostic = gl.getProgramInfoLog(program) ?? 'no program diagnostic was provided'; + throw new Error(`Camera mask refinement WebGL program linking failed: ${diagnostic}`); + } + return program; + } catch (error) { + if (program != null) { + gl.deleteProgram(program); + } + throw error; + } finally { + gl.deleteShader(vertexShader); + if (fragmentShader != null) { + gl.deleteShader(fragmentShader); + } + } +} + +function requireUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name: string): WebGLUniformLocation { + const location = gl.getUniformLocation(program, name); + if (location == null) { + throw new Error(`Camera mask refinement WebGL uniform is unavailable: ${name}`); + } + return location; +} + +function createTexture(gl: WebGL2RenderingContext, unit: number): WebGLTexture { + const texture = gl.createTexture(); + if (texture == null) { + throw new Error('Camera mask refinement could not allocate a WebGL texture'); + } + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return texture; +} + +export class WebCameraEffectWebGLMaskRefiner { + private width = 0; + private height = 0; + private uploadedMaskRevision = -1; + private disposed = false; + + private constructor( + private readonly canvas: OffscreenCanvas, + private readonly gl: WebGL2RenderingContext, + private readonly program: WebGLProgram, + private readonly vertexArray: WebGLVertexArrayObject, + private readonly sourceTexture: WebGLTexture, + private readonly maskTexture: WebGLTexture, + ) {} + + static create(): WebCameraEffectWebGLMaskRefiner | null { + const canvas = new OffscreenCanvas(1, 1); + const gl = canvas.getContext('webgl2', { + alpha: true, + antialias: false, + depth: false, + premultipliedAlpha: false, + preserveDrawingBuffer: true, + stencil: false, + }); + if (gl == null) { + return null; + } + let program: WebGLProgram | null = null; + let vertexArray: WebGLVertexArrayObject | null = null; + let sourceTexture: WebGLTexture | null = null; + let maskTexture: WebGLTexture | null = null; + try { + program = createProgram(gl); + vertexArray = gl.createVertexArray(); + if (vertexArray == null) { + throw new Error('Camera mask refinement could not allocate a WebGL vertex array'); + } + sourceTexture = createTexture(gl, 0); + maskTexture = createTexture(gl, 1); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, SEG_INPUT_EDGE, SEG_INPUT_EDGE, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.useProgram(program); + gl.uniform1i(requireUniform(gl, program, 'sourceTexture'), 0); + gl.uniform1i(requireUniform(gl, program, 'maskTexture'), 1); + gl.bindVertexArray(vertexArray); + gl.disable(gl.BLEND); + gl.disable(gl.CULL_FACE); + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.DITHER); + return new WebCameraEffectWebGLMaskRefiner(canvas, gl, program, vertexArray, sourceTexture, maskTexture); + } catch (error) { + if (sourceTexture != null) gl.deleteTexture(sourceTexture); + if (maskTexture != null) gl.deleteTexture(maskTexture); + if (vertexArray != null) gl.deleteVertexArray(vertexArray); + if (program != null) gl.deleteProgram(program); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + throw error; + } + } + + refine( + source: VideoFrame, + mask: OffscreenCanvas, + maskRevision: number, + width: number, + height: number, + ): OffscreenCanvas | null { + if (this.disposed) { + throw new Error('Cannot refine a camera mask with a disposed WebGL owner'); + } + if (this.gl.isContextLost()) { + return null; + } + if (this.width !== width || this.height !== height) { + this.width = width; + this.height = height; + this.canvas.width = width; + this.canvas.height = height; + this.gl.viewport(0, 0, width, height); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.bindTexture(this.gl.TEXTURE_2D, this.sourceTexture); + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, + this.gl.RGBA, + width, + height, + 0, + this.gl.RGBA, + this.gl.UNSIGNED_BYTE, + null, + ); + } + this.gl.pixelStorei(this.gl.UNPACK_FLIP_Y_WEBGL, 0); + this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.bindTexture(this.gl.TEXTURE_2D, this.sourceTexture); + this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, source); + if (this.uploadedMaskRevision !== maskRevision) { + this.gl.activeTexture(this.gl.TEXTURE1); + this.gl.bindTexture(this.gl.TEXTURE_2D, this.maskTexture); + this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, mask); + this.uploadedMaskRevision = maskRevision; + } + this.gl.useProgram(this.program); + this.gl.bindVertexArray(this.vertexArray); + this.gl.drawArrays(this.gl.TRIANGLES, 0, 3); + this.gl.flush(); + return this.canvas; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.gl.deleteTexture(this.sourceTexture); + this.gl.deleteTexture(this.maskTexture); + this.gl.deleteVertexArray(this.vertexArray); + this.gl.deleteProgram(this.program); + this.gl.getExtension('WEBGL_lose_context')?.loseContext(); + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants.ts new file mode 100644 index 000000000..eeed3edf5 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const WEB_GPU_SHADER_STAGE_FRAGMENT: GPUShaderStageFlags = 2; +export const WEB_GPU_SHADER_STAGE_COMPUTE: GPUShaderStageFlags = 4; + +export const WEB_GPU_BUFFER_USAGE_COPY_DST: GPUBufferUsageFlags = 8; +export const WEB_GPU_BUFFER_USAGE_UNIFORM: GPUBufferUsageFlags = 64; +export const WEB_GPU_BUFFER_USAGE_STORAGE: GPUBufferUsageFlags = 128; + +export const WEB_GPU_TEXTURE_USAGE_COPY_DST: GPUTextureUsageFlags = 2; +export const WEB_GPU_TEXTURE_USAGE_TEXTURE_BINDING: GPUTextureUsageFlags = 4; +export const WEB_GPU_TEXTURE_USAGE_STORAGE_BINDING: GPUTextureUsageFlags = 8; +export const WEB_GPU_TEXTURE_USAGE_RENDER_ATTACHMENT: GPUTextureUsageFlags = 16; diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUPipelines.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUPipelines.ts new file mode 100644 index 000000000..720ef4725 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUPipelines.ts @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + WEB_GPU_SHADER_STAGE_COMPUTE, + WEB_GPU_SHADER_STAGE_FRAGMENT, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants'; +import { + CAMERA_BLUR_SHADER, + CAMERA_COMPOSITE_SHADER, + CAMERA_COPY_SHADER, + CAMERA_COVER_SHADER, + CAMERA_MASK_SHADER, + CAMERA_PREPROCESS_SHADER, + CAMERA_SOURCE_SHADER, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGPUShaders'; + +export interface WebCameraEffectWebGPUPipelines { + readonly sourceLayout: GPUBindGroupLayout; + readonly preprocessLayout: GPUBindGroupLayout; + readonly blurLayout: GPUBindGroupLayout; + readonly copyLayout: GPUBindGroupLayout; + readonly coverLayout: GPUBindGroupLayout; + readonly compositeLayout: GPUBindGroupLayout; + readonly maskLayout: GPUBindGroupLayout; + readonly source: GPURenderPipeline; + readonly preprocess: GPURenderPipeline; + readonly blur: GPURenderPipeline; + readonly copy: GPURenderPipeline; + readonly cover: GPURenderPipeline; + readonly composite: GPURenderPipeline; + readonly mask: GPUComputePipeline; +} + +interface WebCameraEffectRenderPipelineCreation { + readonly device: GPUDevice; + readonly label: string; + readonly code: string; + readonly layout: GPUBindGroupLayout; + readonly format: GPUTextureFormat; +} + +function renderPipeline({ + device, + label, + code, + layout, + format, +}: WebCameraEffectRenderPipelineCreation): GPURenderPipeline { + const module = device.createShaderModule({label, code}); + return device.createRenderPipeline({ + label, + layout: device.createPipelineLayout({bindGroupLayouts: [layout]}), + vertex: {module, entryPoint: 'vertexMain'}, + fragment: {module, entryPoint: 'fragmentMain', targets: [{format}]}, + primitive: {topology: 'triangle-list'}, + }); +} + +export function createWebCameraEffectWebGPUPipelines( + device: GPUDevice, + canvasFormat: GPUTextureFormat, +): WebCameraEffectWebGPUPipelines { + const sourceLayout = device.createBindGroupLayout({ + label: 'camera-source-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, externalTexture: {}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + ], + }); + const preprocessLayout = device.createBindGroupLayout({ + label: 'camera-preprocess-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, externalTexture: {}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + {binding: 2, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, buffer: {type: 'storage'}}, + ], + }); + const blurLayout = device.createBindGroupLayout({ + label: 'camera-blur-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + {binding: 2, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, buffer: {type: 'uniform'}}, + ], + }); + const copyLayout = device.createBindGroupLayout({ + label: 'camera-copy-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + ], + }); + const coverLayout = device.createBindGroupLayout({ + label: 'camera-cover-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + {binding: 2, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, buffer: {type: 'uniform'}}, + ], + }); + const compositeLayout = device.createBindGroupLayout({ + label: 'camera-composite-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 2, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, texture: {sampleType: 'float'}}, + {binding: 3, visibility: WEB_GPU_SHADER_STAGE_FRAGMENT, sampler: {type: 'filtering'}}, + ], + }); + const maskLayout = device.createBindGroupLayout({ + label: 'camera-mask-layout', + entries: [ + {binding: 0, visibility: WEB_GPU_SHADER_STAGE_COMPUTE, buffer: {type: 'read-only-storage'}}, + {binding: 1, visibility: WEB_GPU_SHADER_STAGE_COMPUTE, buffer: {type: 'storage'}}, + { + binding: 2, + visibility: WEB_GPU_SHADER_STAGE_COMPUTE, + storageTexture: {access: 'write-only', format: 'rgba8unorm'}, + }, + {binding: 3, visibility: WEB_GPU_SHADER_STAGE_COMPUTE, buffer: {type: 'uniform'}}, + ], + }); + const maskModule = device.createShaderModule({label: 'camera-mask', code: CAMERA_MASK_SHADER}); + return { + sourceLayout, + preprocessLayout, + blurLayout, + copyLayout, + coverLayout, + compositeLayout, + maskLayout, + source: renderPipeline({ + device, + label: 'camera-source', + code: CAMERA_SOURCE_SHADER, + layout: sourceLayout, + format: 'rgba8unorm', + }), + preprocess: renderPipeline({ + device, + label: 'camera-preprocess', + code: CAMERA_PREPROCESS_SHADER, + layout: preprocessLayout, + format: 'rgba8unorm', + }), + blur: renderPipeline({ + device, + label: 'camera-blur', + code: CAMERA_BLUR_SHADER, + layout: blurLayout, + format: 'rgba8unorm', + }), + copy: renderPipeline({ + device, + label: 'camera-copy', + code: CAMERA_COPY_SHADER, + layout: copyLayout, + format: canvasFormat, + }), + cover: renderPipeline({ + device, + label: 'camera-cover', + code: CAMERA_COVER_SHADER, + layout: coverLayout, + format: 'rgba8unorm', + }), + composite: renderPipeline({ + device, + label: 'camera-composite', + code: CAMERA_COMPOSITE_SHADER, + layout: compositeLayout, + format: canvasFormat, + }), + mask: device.createComputePipeline({ + label: 'camera-mask', + layout: device.createPipelineLayout({bindGroupLayouts: [maskLayout]}), + compute: {module: maskModule, entryPoint: 'computeMain'}, + }), + }; +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPURenderer.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPURenderer.ts new file mode 100644 index 000000000..1ee4a4c89 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPURenderer.ts @@ -0,0 +1,973 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + collectSettledFailures, + throwCollectedFailures, +} from '@app/features/voice/utils/camera-effects/AggregateOperations'; +import {CameraBackgroundMode} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import type { + WebCameraEffectCustomFrame, + WebCameraEffectCustomFrameSource, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomImage'; +import { + cameraEffectBlurPixels, + requireCameraEffectBlurStrength, + validateCameraEffectFrameDimensions, + WEB_CAMERA_EFFECT_SEGMENTATION_MIN_INTERVAL_MS, + WebCameraEffectBackend, + type WebCameraPipelineConfig, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; +import type {WebCameraEffectRenderer} from '@app/features/voice/utils/camera-effects/WebCameraEffectRenderer'; +import {WebCameraEffectSegmentationOwner} from '@app/features/voice/utils/camera-effects/WebCameraEffectSegmentationOwner'; +import { + WEB_GPU_BUFFER_USAGE_COPY_DST, + WEB_GPU_BUFFER_USAGE_STORAGE, + WEB_GPU_BUFFER_USAGE_UNIFORM, + WEB_GPU_TEXTURE_USAGE_COPY_DST, + WEB_GPU_TEXTURE_USAGE_RENDER_ATTACHMENT, + WEB_GPU_TEXTURE_USAGE_STORAGE_BINDING, + WEB_GPU_TEXTURE_USAGE_TEXTURE_BINDING, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGPUConstants'; +import { + createWebCameraEffectWebGPUPipelines, + type WebCameraEffectWebGPUPipelines, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGPUPipelines'; +import { + loadWebSelfieRuntime, + MissingSegmentationAlphasOutputError, + SEG_INPUT_EDGE, + SEG_INPUT_NAME, + SEG_INPUT_PIXELS, + SEG_OUTPUT_NAME, + type WebSelfieOrtModule, +} from '@app/features/voice/utils/camera-effects/WebSelfieSegmenter'; +import type * as OrtNamespace from 'onnxruntime-web/webgpu'; +import invariant from 'tiny-invariant'; + +class WebGPUCameraFrameResourcesUnavailableError extends Error { + constructor() { + super('WebGPU camera frame resources are unavailable'); + this.name = 'WebGPUCameraFrameResourcesUnavailableError'; + } +} + +class MissingSegmentationGPUFloatMaskError extends Error { + constructor() { + super('Segmentation model did not produce the required GPU-resident float mask'); + this.name = 'MissingSegmentationGPUFloatMaskError'; + } +} + +class MissingWebGPUCameraFrameTexturesError extends Error { + constructor() { + super('Cannot build WebGPU camera bind groups without frame textures'); + this.name = 'MissingWebGPUCameraFrameTexturesError'; + } +} + +const INPUT_BUFFER_BYTES = 3 * SEG_INPUT_PIXELS * Float32Array.BYTES_PER_ELEMENT; +const MASK_BUFFER_BYTES = SEG_INPUT_PIXELS * Float32Array.BYTES_PER_ELEMENT; + +interface CustomTexture { + readonly texture: GPUTexture; + readonly width: number; + readonly height: number; + frameIndex: number; +} + +interface FrameBindGroups { + readonly sourceCopy: GPUBindGroup; + readonly backgroundCopy: GPUBindGroup; + readonly horizontalBlur: GPUBindGroup; + readonly verticalBlur: GPUBindGroup; + readonly composite: GPUBindGroup; +} + +interface FrameConfigurationResources { + readonly horizontalBlurParamsBuffer: GPUBuffer; + readonly verticalBlurParamsBuffer: GPUBuffer; + readonly coverParamsBuffer: GPUBuffer; + readonly frameBindGroups: FrameBindGroups; + readonly customCoverBindGroup: GPUBindGroup | null; +} + +interface CustomBackgroundCoverScale { + readonly scaleX: number; + readonly scaleY: number; +} + +interface WebCameraEffectWebGPUInitialization { + readonly canvas: OffscreenCanvas; + readonly context: GPUCanvasContext; + readonly canvasFormat: GPUTextureFormat; + readonly device: GPUDevice; + readonly ort: WebSelfieOrtModule; + readonly session: OrtNamespace.InferenceSession; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function resolveValidationDetail(validationError: GPUError | null): string { + if (validationError == null) { + return ''; + } + return `; validation failed: ${validationError.message}`; +} + +async function popValidationErrorScope( + device: GPUDevice, + validationScopeActive: boolean, + validationFailures: Array, +): Promise { + if (!validationScopeActive) { + return null; + } + try { + return await device.popErrorScope(); + } catch (validationFailure) { + validationFailures.push(validationFailure); + return null; + } +} + +function resolveCustomBackgroundCoverScale(imageAspect: number, canvasAspect: number): CustomBackgroundCoverScale { + if (imageAspect > canvasAspect) { + return { + scaleX: canvasAspect / imageAspect, + scaleY: 1, + }; + } + return { + scaleX: 1, + scaleY: imageAspect / canvasAspect, + }; +} + +function GPUBooleanFlag(value: boolean): number { + if (value) { + return 1; + } + return 0; +} + +function destroyGPUTexture(texture: GPUTexture | null): void { + if (texture == null) { + return; + } + texture.destroy(); +} + +function destroyGPUBuffer(buffer: GPUBuffer | null): void { + if (buffer == null) { + return; + } + buffer.destroy(); +} + +function destroyCustomTexture(customTexture: CustomTexture | null): void { + if (customTexture == null) { + return; + } + customTexture.texture.destroy(); +} + +function collectInferenceOutputDisposalFailures( + outputs: Readonly>, +): ReadonlyArray { + const failures: Array = []; + for (const output of Object.values(outputs)) { + try { + output.dispose(); + } catch (error) { + failures.push(error); + } + } + return failures; +} + +async function collectWebGPUInitializationCleanupFailures( + renderer: WebCameraEffectWebGPURenderer | null, + session: OrtNamespace.InferenceSession | null, + device: GPUDevice, +): Promise> { + if (renderer != null) { + return collectSettledFailures([renderer.dispose()]); + } + return collectSettledFailures([ + Promise.resolve().then(() => { + if (session != null) { + session.release(); + } + }), + Promise.resolve().then(() => device.destroy()), + ]); +} + +function requireGPUMask(output: OrtNamespace.Tensor): OrtNamespace.Tensor { + let elements = 1; + for (const dimension of output.dims) { + elements *= dimension; + } + if (output.type !== 'float32') { + throw new MissingSegmentationGPUFloatMaskError(); + } + if (output.location !== 'gpu-buffer') { + throw new MissingSegmentationGPUFloatMaskError(); + } + if (elements !== SEG_INPUT_PIXELS) { + throw new MissingSegmentationGPUFloatMaskError(); + } + return output; +} + +function beginRenderPass(encoder: GPUCommandEncoder, target: GPUTextureView): GPURenderPassEncoder { + return encoder.beginRenderPass({ + colorAttachments: [ + { + view: target, + clearValue: {r: 0, g: 0, b: 0, a: 1}, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); +} + +function drawRenderPass(pass: GPURenderPassEncoder, pipeline: GPURenderPipeline, bindGroup: GPUBindGroup): void { + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); +} + +export class WebCameraEffectWebGPURenderer implements WebCameraEffectRenderer { + readonly backend = WebCameraEffectBackend.WEB_GPU; + private readonly canvas: OffscreenCanvas; + private readonly canvasContext: GPUCanvasContext; + private readonly canvasFormat: GPUTextureFormat; + private readonly device: GPUDevice; + private readonly session: OrtNamespace.InferenceSession; + private readonly pipelines: WebCameraEffectWebGPUPipelines; + private readonly sampler: GPUSampler; + private readonly inputBuffer: GPUBuffer; + private readonly inputTensor: OrtNamespace.Tensor; + private readonly smoothedMaskBuffer: GPUBuffer; + private horizontalBlurParamsBuffer: GPUBuffer; + private verticalBlurParamsBuffer: GPUBuffer; + private coverParamsBuffer: GPUBuffer; + private readonly maskParamsBuffer: GPUBuffer; + private readonly horizontalBlurParams = new Float32Array(4); + private readonly verticalBlurParams = new Float32Array(4); + private readonly coverParams = new Float32Array(4); + private readonly maskParams = new ArrayBuffer(16); + private readonly maskParamsView = new DataView(this.maskParams); + private readonly segmentationOwner = new WebCameraEffectSegmentationOwner(); + private readonly preprocessTarget: GPUTexture; + private readonly preprocessTargetView: GPUTextureView; + private readonly maskTexture: GPUTexture; + private readonly maskTextureView: GPUTextureView; + private config: WebCameraPipelineConfig = {background: null}; + private customFrameSource: WebCameraEffectCustomFrameSource | null = null; + private customTexture: CustomTexture | null = null; + private sourceTexture: GPUTexture | null = null; + private sourceTextureView: GPUTextureView | null = null; + private blurTexture: GPUTexture | null = null; + private blurTextureView: GPUTextureView | null = null; + private backgroundTexture: GPUTexture | null = null; + private backgroundTextureView: GPUTextureView | null = null; + private frameBindGroups: FrameBindGroups | null = null; + private customCoverBindGroup: GPUBindGroup | null = null; + private width = 0; + private height = 0; + private customBackgroundDirty = false; + private lastSegmentationAt = Number.NEGATIVE_INFINITY; + private maskPrimed = false; + private maskReady = false; + private disposed = false; + private disposePromise: Promise | null = null; + private deviceLostReason: string | null = null; + + private constructor({canvas, context, canvasFormat, device, ort, session}: WebCameraEffectWebGPUInitialization) { + this.canvas = canvas; + this.canvasContext = context; + this.canvasFormat = canvasFormat; + this.device = device; + this.session = session; + this.pipelines = createWebCameraEffectWebGPUPipelines(device, canvasFormat); + this.sampler = device.createSampler({ + label: 'camera-linear-sampler', + addressModeU: 'clamp-to-edge', + addressModeV: 'clamp-to-edge', + magFilter: 'linear', + minFilter: 'linear', + }); + this.inputBuffer = device.createBuffer({ + label: 'camera-segmentation-input', + size: INPUT_BUFFER_BYTES, + usage: WEB_GPU_BUFFER_USAGE_STORAGE | WEB_GPU_BUFFER_USAGE_COPY_DST, + }); + this.inputTensor = ort.Tensor.fromGpuBuffer(this.inputBuffer, { + dataType: 'float32', + dims: [1, 3, SEG_INPUT_EDGE, SEG_INPUT_EDGE], + }); + this.smoothedMaskBuffer = device.createBuffer({ + label: 'camera-smoothed-mask', + size: MASK_BUFFER_BYTES, + usage: WEB_GPU_BUFFER_USAGE_STORAGE | WEB_GPU_BUFFER_USAGE_COPY_DST, + }); + this.horizontalBlurParamsBuffer = this.uniformBuffer('camera-horizontal-blur-params'); + this.verticalBlurParamsBuffer = this.uniformBuffer('camera-vertical-blur-params'); + this.coverParamsBuffer = this.uniformBuffer('camera-cover-params'); + this.maskParamsBuffer = this.uniformBuffer('camera-mask-params'); + this.preprocessTarget = device.createTexture({ + label: 'camera-preprocess-target', + size: [SEG_INPUT_EDGE, SEG_INPUT_EDGE], + format: 'rgba8unorm', + usage: WEB_GPU_TEXTURE_USAGE_RENDER_ATTACHMENT, + }); + this.maskTexture = device.createTexture({ + label: 'camera-mask', + size: [SEG_INPUT_EDGE, SEG_INPUT_EDGE], + format: 'rgba8unorm', + usage: WEB_GPU_TEXTURE_USAGE_STORAGE_BINDING | WEB_GPU_TEXTURE_USAGE_TEXTURE_BINDING, + }); + this.preprocessTargetView = this.preprocessTarget.createView(); + this.maskTextureView = this.maskTexture.createView(); + void device.lost + .then((info) => { + if (!this.disposed) { + this.deviceLostReason = `${info.reason}: ${info.message}`; + } + }) + .catch(() => {}); + } + + static async create( + canvas: OffscreenCanvas, + config: WebCameraPipelineConfig, + customFrameSource: WebCameraEffectCustomFrameSource | null, + ): Promise { + const GPU = navigator.gpu; + if (GPU == null) { + throw new Error('WebGPU is unavailable'); + } + const adapter = await GPU.requestAdapter({powerPreference: 'high-performance'}); + if (adapter == null) { + throw new Error('WebGPU did not provide an adapter'); + } + const device = await adapter.requestDevice(); + const context = canvas.getContext('webgpu') as GPUCanvasContext | null; + if (context == null) { + device.destroy(); + throw new Error('OffscreenCanvas WebGPU context is unavailable'); + } + const format = GPU.getPreferredCanvasFormat(); + context.configure({device, format, alphaMode: 'opaque'}); + let session: OrtNamespace.InferenceSession | null = null; + let renderer: WebCameraEffectWebGPURenderer | null = null; + let validationScopeActive = true; + device.pushErrorScope('validation'); + try { + const {ort, modelBytes} = await loadWebSelfieRuntime(); + session = await ort.InferenceSession.create(modelBytes, { + executionProviders: [{name: 'webgpu', device, preferredLayout: 'NCHW'}], + graphOptimizationLevel: 'all', + logSeverityLevel: 3, + preferredOutputLocation: {[SEG_OUTPUT_NAME]: 'gpu-buffer'}, + }); + renderer = new WebCameraEffectWebGPURenderer({ + canvas, + context, + canvasFormat: format, + device, + ort, + session, + }); + await renderer.configure(config, customFrameSource); + await renderer.warmup(); + const validationError = await device.popErrorScope(); + validationScopeActive = false; + if (validationError != null) { + throw new Error(`WebGPU validation failed: ${validationError.message}`); + } + return renderer; + } catch (error) { + const validationFailures: Array = []; + const validationError = await popValidationErrorScope(device, validationScopeActive, validationFailures); + const validationDetail = resolveValidationDetail(validationError); + const initializationError = new Error( + `WebGPU camera effect initialization failed: ${errorMessage(error)}${validationDetail}`, + { + cause: error, + }, + ); + const cleanupFailures = await collectWebGPUInitializationCleanupFailures(renderer, session, device); + throwCollectedFailures({ + failures: [initializationError, ...validationFailures, ...cleanupFailures], + message: 'WebGPU camera effect initialization and cleanup failed', + }); + } + } + + async configure( + config: WebCameraPipelineConfig, + customFrameSource: WebCameraEffectCustomFrameSource | null, + ): Promise { + this.requireActive(); + if (config.background != null) { + requireCameraEffectBlurStrength(config.background.blurStrength); + } + const customBackground = config.background?.mode === CameraBackgroundMode.CUSTOM; + if (customBackground !== (customFrameSource != null)) { + throw new Error('Camera effect custom frame source does not match its configuration'); + } + let nextCustomTexture = this.customTexture; + if (this.customFrameSource !== customFrameSource) { + nextCustomTexture = await this.createCustomTexture(customFrameSource); + } + let nextFrameConfiguration: FrameConfigurationResources | null = null; + try { + if (this.width > 0 && this.height > 0) { + nextFrameConfiguration = await this.createFrameConfigurationResources(config, nextCustomTexture); + } + const backgroundLifecycleChanged = (this.config.background == null) !== (config.background == null); + if (backgroundLifecycleChanged) { + this.segmentationOwner.advanceLifecycle(); + } + } catch (error) { + const cleanupFailures = await collectSettledFailures([ + Promise.resolve().then(() => { + if (nextCustomTexture !== this.customTexture) { + destroyCustomTexture(nextCustomTexture); + } + }), + ]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'WebGPU camera effect configuration failed during cleanup', + }); + } + const backgroundLifecycleChanged = (this.config.background == null) !== (config.background == null); + const previousCustomTexture = this.customTexture; + const previousHorizontalBlurParamsBuffer = this.horizontalBlurParamsBuffer; + const previousVerticalBlurParamsBuffer = this.verticalBlurParamsBuffer; + const previousCoverParamsBuffer = this.coverParamsBuffer; + this.customTexture = nextCustomTexture; + this.customFrameSource = customFrameSource; + this.config = config; + if (nextFrameConfiguration != null) { + this.horizontalBlurParamsBuffer = nextFrameConfiguration.horizontalBlurParamsBuffer; + this.verticalBlurParamsBuffer = nextFrameConfiguration.verticalBlurParamsBuffer; + this.coverParamsBuffer = nextFrameConfiguration.coverParamsBuffer; + this.frameBindGroups = nextFrameConfiguration.frameBindGroups; + this.customCoverBindGroup = nextFrameConfiguration.customCoverBindGroup; + } + if (backgroundLifecycleChanged) { + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskPrimed = false; + this.maskReady = false; + } + this.customBackgroundDirty = nextCustomTexture != null; + if (previousCustomTexture !== nextCustomTexture) { + destroyCustomTexture(previousCustomTexture); + } + if (nextFrameConfiguration != null) { + destroyGPUBuffer(previousHorizontalBlurParamsBuffer); + destroyGPUBuffer(previousVerticalBlurParamsBuffer); + destroyGPUBuffer(previousCoverParamsBuffer); + } + } + + private async createFrameConfigurationResources( + config: WebCameraPipelineConfig, + customTexture: CustomTexture | null, + ): Promise { + let horizontalBlurParamsBuffer: GPUBuffer | null = null; + let verticalBlurParamsBuffer: GPUBuffer | null = null; + let coverParamsBuffer: GPUBuffer | null = null; + try { + horizontalBlurParamsBuffer = this.uniformBuffer('camera-horizontal-blur-params'); + verticalBlurParamsBuffer = this.uniformBuffer('camera-vertical-blur-params'); + coverParamsBuffer = this.uniformBuffer('camera-cover-params'); + this.updateFrameParams( + config, + customTexture, + horizontalBlurParamsBuffer, + verticalBlurParamsBuffer, + coverParamsBuffer, + ); + return { + horizontalBlurParamsBuffer, + verticalBlurParamsBuffer, + coverParamsBuffer, + frameBindGroups: this.createFrameBindGroups(horizontalBlurParamsBuffer, verticalBlurParamsBuffer), + customCoverBindGroup: this.createCustomCoverBindGroup(customTexture, coverParamsBuffer), + }; + } catch (error) { + const cleanupFailures = await collectSettledFailures([ + Promise.resolve().then(() => destroyGPUBuffer(horizontalBlurParamsBuffer)), + Promise.resolve().then(() => destroyGPUBuffer(verticalBlurParamsBuffer)), + Promise.resolve().then(() => destroyGPUBuffer(coverParamsBuffer)), + ]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'WebGPU camera effect frame configuration failed during cleanup', + }); + } + } + + private async createCustomTexture( + customFrameSource: WebCameraEffectCustomFrameSource | null, + ): Promise { + if (customFrameSource == null) { + return null; + } + const lease = customFrameSource.acquireFrame(0); + try { + return await this.loadCustomTexture(lease.frame); + } finally { + lease.release(); + } + } + + async render(frame: VideoFrame, now: number): Promise { + this.requireActive(); + const outputFrameAdmission = this.segmentationOwner.admitOutputFrame(); + if (outputFrameAdmission != null) { + await outputFrameAdmission; + this.requireActive(); + } + this.ensureSize(frame.displayWidth, frame.displayHeight); + this.refreshCustomTexture(now); + const externalTexture = this.device.importExternalTexture({source: frame}); + const sourceBindGroup = this.device.createBindGroup({ + label: 'camera-source-frame', + layout: this.pipelines.sourceLayout, + entries: [ + {binding: 0, resource: externalTexture}, + {binding: 1, resource: this.sampler}, + ], + }); + const sourceView = this.sourceTextureView; + const backgroundView = this.backgroundTextureView; + const frameBindGroups = this.frameBindGroups; + if (sourceView == null) { + throw new WebGPUCameraFrameResourcesUnavailableError(); + } + if (backgroundView == null) { + throw new WebGPUCameraFrameResourcesUnavailableError(); + } + if (frameBindGroups == null) { + throw new WebGPUCameraFrameResourcesUnavailableError(); + } + const encoder = this.device.createCommandEncoder({label: 'camera-frame'}); + drawRenderPass(beginRenderPass(encoder, sourceView), this.pipelines.source, sourceBindGroup); + const background = this.config.background; + if (background != null && background.mode === CameraBackgroundMode.BLUR) { + const blurView = this.blurTextureView; + if (blurView == null) { + throw new Error('WebGPU camera blur texture is unavailable'); + } + drawRenderPass(beginRenderPass(encoder, blurView), this.pipelines.blur, frameBindGroups.horizontalBlur); + drawRenderPass(beginRenderPass(encoder, backgroundView), this.pipelines.blur, frameBindGroups.verticalBlur); + } else if (background != null && background.mode === CameraBackgroundMode.CUSTOM && this.customBackgroundDirty) { + const coverBindGroup = this.customCoverBindGroup; + if (coverBindGroup == null) { + throw new Error('WebGPU custom camera background resources are unavailable'); + } + drawRenderPass(beginRenderPass(encoder, backgroundView), this.pipelines.cover, coverBindGroup); + this.customBackgroundDirty = false; + } + const outputView = this.canvasContext.getCurrentTexture().createView(); + if (background == null) { + drawRenderPass(beginRenderPass(encoder, outputView), this.pipelines.copy, frameBindGroups.sourceCopy); + } else if (!this.maskReady) { + drawRenderPass(beginRenderPass(encoder, outputView), this.pipelines.copy, frameBindGroups.backgroundCopy); + } else { + drawRenderPass(beginRenderPass(encoder, outputView), this.pipelines.composite, frameBindGroups.composite); + } + this.device.queue.submit([encoder.finish()]); + if (this.config.background != null) { + this.maybeStartSegmentation(frame, now); + } + } + + dispose(): Promise { + if (this.disposePromise == null) { + this.disposePromise = this.disposeOwned(); + } + return this.disposePromise; + } + + private async disposeOwned(): Promise { + invariant(!this.disposed, 'WebGPU camera effect disposal must have one owner'); + this.disposed = true; + this.segmentationOwner.advanceLifecycle(); + let segmentationFailures: ReadonlyArray; + try { + segmentationFailures = await this.segmentationOwner.settleForDisposal(); + } catch (error) { + segmentationFailures = [error]; + } + const inputFailures = await collectSettledFailures([Promise.resolve().then(() => this.inputTensor.dispose())]); + const sessionFailures = await collectSettledFailures([Promise.resolve().then(() => this.session.release())]); + const resourceFailures = await collectSettledFailures([ + Promise.resolve().then(() => destroyGPUTexture(this.sourceTexture)), + Promise.resolve().then(() => destroyGPUTexture(this.blurTexture)), + Promise.resolve().then(() => destroyGPUTexture(this.backgroundTexture)), + Promise.resolve().then(() => destroyCustomTexture(this.customTexture)), + Promise.resolve().then(() => this.preprocessTarget.destroy()), + Promise.resolve().then(() => this.maskTexture.destroy()), + Promise.resolve().then(() => this.inputBuffer.destroy()), + Promise.resolve().then(() => this.smoothedMaskBuffer.destroy()), + Promise.resolve().then(() => this.horizontalBlurParamsBuffer.destroy()), + Promise.resolve().then(() => this.verticalBlurParamsBuffer.destroy()), + Promise.resolve().then(() => this.coverParamsBuffer.destroy()), + Promise.resolve().then(() => this.maskParamsBuffer.destroy()), + ]); + const deviceFailures = await collectSettledFailures([Promise.resolve().then(() => this.device.destroy())]); + throwCollectedFailures({ + failures: [...segmentationFailures, ...inputFailures, ...sessionFailures, ...resourceFailures, ...deviceFailures], + message: 'WebGPU camera effect teardown failed', + }); + } + + private uniformBuffer(label: string): GPUBuffer { + return this.device.createBuffer({ + label, + size: 16, + usage: WEB_GPU_BUFFER_USAGE_UNIFORM | WEB_GPU_BUFFER_USAGE_COPY_DST, + }); + } + + private async warmup(): Promise { + const width = this.canvas.width; + const height = this.canvas.height; + validateCameraEffectFrameDimensions(width, height); + const probeCanvas = new OffscreenCanvas(width, height); + const probeContext = probeCanvas.getContext('2d'); + if (probeContext == null) { + throw new Error('WebGPU camera effect warm-up requires OffscreenCanvas 2D'); + } + probeContext.fillStyle = '#000'; + probeContext.fillRect(0, 0, width, height); + const frame = new VideoFrame(probeCanvas, {timestamp: 0}); + try { + await this.render(frame, 0); + await this.segmentationOwner.settlePhysicalOperation(); + } finally { + frame.close(); + } + this.device.queue.writeBuffer(this.smoothedMaskBuffer, 0, new Uint8Array(MASK_BUFFER_BYTES)); + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskPrimed = false; + this.maskReady = false; + } + + private requireActive(): void { + if (this.disposed) { + throw new Error('Cannot use a disposed WebGPU camera effect renderer'); + } + this.segmentationOwner.requireNoDeferredFailure(); + if (this.deviceLostReason != null) { + throw new Error(`WebGPU camera effect device was lost: ${this.deviceLostReason}`); + } + } + + private ensureSize(width: number, height: number): void { + validateCameraEffectFrameDimensions(width, height); + if (this.width === width && this.height === height) { + return; + } + this.width = width; + this.height = height; + this.canvas.width = width; + this.canvas.height = height; + this.canvasContext.configure({device: this.device, format: this.canvasFormat, alphaMode: 'opaque'}); + destroyGPUTexture(this.sourceTexture); + destroyGPUTexture(this.blurTexture); + destroyGPUTexture(this.backgroundTexture); + this.sourceTexture = this.frameTexture('camera-source'); + this.blurTexture = this.frameTexture('camera-blur'); + this.backgroundTexture = this.frameTexture('camera-background'); + this.sourceTextureView = this.sourceTexture.createView(); + this.blurTextureView = this.blurTexture.createView(); + this.backgroundTextureView = this.backgroundTexture.createView(); + this.updateFrameParams( + this.config, + this.customTexture, + this.horizontalBlurParamsBuffer, + this.verticalBlurParamsBuffer, + this.coverParamsBuffer, + ); + this.frameBindGroups = this.createFrameBindGroups(this.horizontalBlurParamsBuffer, this.verticalBlurParamsBuffer); + this.customCoverBindGroup = this.createCustomCoverBindGroup(this.customTexture, this.coverParamsBuffer); + this.customBackgroundDirty = this.customTexture != null; + this.device.queue.writeBuffer(this.smoothedMaskBuffer, 0, new Uint8Array(MASK_BUFFER_BYTES)); + this.lastSegmentationAt = Number.NEGATIVE_INFINITY; + this.maskPrimed = false; + this.maskReady = false; + } + + private frameTexture(label: string): GPUTexture { + return this.device.createTexture({ + label, + size: [this.width, this.height], + format: 'rgba8unorm', + usage: WEB_GPU_TEXTURE_USAGE_RENDER_ATTACHMENT | WEB_GPU_TEXTURE_USAGE_TEXTURE_BINDING, + }); + } + + private updateFrameParams( + config: WebCameraPipelineConfig, + customTexture: CustomTexture | null, + horizontalBlurParamsBuffer: GPUBuffer, + verticalBlurParamsBuffer: GPUBuffer, + coverParamsBuffer: GPUBuffer, + ): void { + let radius = 0; + const background = config.background; + if (background != null) { + radius = cameraEffectBlurPixels(background.blurStrength); + } + this.horizontalBlurParams[0] = 1 / this.width; + this.horizontalBlurParams[1] = 0; + this.horizontalBlurParams[2] = radius; + this.horizontalBlurParams[3] = 0; + this.device.queue.writeBuffer(horizontalBlurParamsBuffer, 0, this.horizontalBlurParams); + this.verticalBlurParams[0] = 0; + this.verticalBlurParams[1] = 1 / this.height; + this.verticalBlurParams[2] = radius; + this.verticalBlurParams[3] = 0; + this.device.queue.writeBuffer(verticalBlurParamsBuffer, 0, this.verticalBlurParams); + if (customTexture == null) { + return; + } + const imageAspect = customTexture.width / customTexture.height; + const canvasAspect = this.width / this.height; + const {scaleX, scaleY} = resolveCustomBackgroundCoverScale(imageAspect, canvasAspect); + this.coverParams[0] = scaleX; + this.coverParams[1] = scaleY; + this.coverParams[2] = (1 - scaleX) / 2; + this.coverParams[3] = (1 - scaleY) / 2; + this.device.queue.writeBuffer(coverParamsBuffer, 0, this.coverParams); + } + + private createFrameBindGroups( + horizontalBlurParamsBuffer: GPUBuffer, + verticalBlurParamsBuffer: GPUBuffer, + ): FrameBindGroups { + const sourceView = this.sourceTextureView; + const blurView = this.blurTextureView; + const backgroundView = this.backgroundTextureView; + if (sourceView == null) { + throw new MissingWebGPUCameraFrameTexturesError(); + } + if (blurView == null) { + throw new MissingWebGPUCameraFrameTexturesError(); + } + if (backgroundView == null) { + throw new MissingWebGPUCameraFrameTexturesError(); + } + return { + sourceCopy: this.device.createBindGroup({ + label: 'camera-source-copy', + layout: this.pipelines.copyLayout, + entries: [ + {binding: 0, resource: sourceView}, + {binding: 1, resource: this.sampler}, + ], + }), + backgroundCopy: this.device.createBindGroup({ + label: 'camera-background-copy', + layout: this.pipelines.copyLayout, + entries: [ + {binding: 0, resource: backgroundView}, + {binding: 1, resource: this.sampler}, + ], + }), + horizontalBlur: this.device.createBindGroup({ + label: 'camera-horizontal-blur', + layout: this.pipelines.blurLayout, + entries: [ + {binding: 0, resource: sourceView}, + {binding: 1, resource: this.sampler}, + {binding: 2, resource: {buffer: horizontalBlurParamsBuffer}}, + ], + }), + verticalBlur: this.device.createBindGroup({ + label: 'camera-vertical-blur', + layout: this.pipelines.blurLayout, + entries: [ + {binding: 0, resource: blurView}, + {binding: 1, resource: this.sampler}, + {binding: 2, resource: {buffer: verticalBlurParamsBuffer}}, + ], + }), + composite: this.device.createBindGroup({ + label: 'camera-composite', + layout: this.pipelines.compositeLayout, + entries: [ + {binding: 0, resource: sourceView}, + {binding: 1, resource: backgroundView}, + {binding: 2, resource: this.maskTextureView}, + {binding: 3, resource: this.sampler}, + ], + }), + }; + } + + private createCustomCoverBindGroup( + customTexture: CustomTexture | null, + coverParamsBuffer: GPUBuffer, + ): GPUBindGroup | null { + if (customTexture == null) { + return null; + } + return this.device.createBindGroup({ + label: 'camera-custom-cover', + layout: this.pipelines.coverLayout, + entries: [ + {binding: 0, resource: customTexture.texture.createView()}, + {binding: 1, resource: this.sampler}, + {binding: 2, resource: {buffer: coverParamsBuffer}}, + ], + }); + } + + private async loadCustomTexture(frame: WebCameraEffectCustomFrame): Promise { + let texture: GPUTexture | null = null; + try { + texture = this.device.createTexture({ + label: 'camera-custom-background', + size: [frame.width, frame.height], + format: 'rgba8unorm', + usage: WEB_GPU_TEXTURE_USAGE_COPY_DST | WEB_GPU_TEXTURE_USAGE_TEXTURE_BINDING, + }); + this.device.queue.copyExternalImageToTexture( + {source: frame.image}, + {texture}, + {width: frame.width, height: frame.height}, + ); + } catch (error) { + const cleanupFailures = await collectSettledFailures([Promise.resolve().then(() => destroyGPUTexture(texture))]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'WebGPU custom camera background upload failed during cleanup', + }); + } + if (texture == null) { + throw new Error('WebGPU custom camera background upload produced no texture'); + } + return {texture, width: frame.width, height: frame.height, frameIndex: frame.index}; + } + + private refreshCustomTexture(now: number): void { + const customFrameSource = this.customFrameSource; + const customTexture = this.customTexture; + if (customFrameSource == null || customTexture == null) { + return; + } + const lease = customFrameSource.acquireFrame(now); + try { + const frame = lease.frame; + if (customTexture.frameIndex === frame.index) { + return; + } + if (customTexture.width !== frame.width || customTexture.height !== frame.height) { + throw new Error('Custom camera background frame dimensions changed after initialization'); + } + this.device.queue.copyExternalImageToTexture( + {source: frame.image}, + {texture: customTexture.texture}, + {width: frame.width, height: frame.height}, + ); + customTexture.frameIndex = frame.index; + this.customBackgroundDirty = true; + } finally { + lease.release(); + } + } + + private maybeStartSegmentation(frame: VideoFrame, now: number): void { + if (!this.segmentationOwner.canStartPhysicalOperation()) { + return; + } + if (now - this.lastSegmentationAt < WEB_CAMERA_EFFECT_SEGMENTATION_MIN_INTERVAL_MS) { + return; + } + this.lastSegmentationAt = now; + let operation: Promise; + try { + this.submitSegmentationPreprocess(frame); + operation = this.executeSegmentation(); + } catch (error) { + operation = Promise.reject(error); + } + this.segmentationOwner.startPhysicalOperation(operation, () => { + this.maskPrimed = true; + this.maskReady = true; + }); + } + + private submitSegmentationPreprocess(frame: VideoFrame): void { + const externalTexture = this.device.importExternalTexture({source: frame}); + const preprocessBindGroup = this.device.createBindGroup({ + label: 'camera-preprocess-frame', + layout: this.pipelines.preprocessLayout, + entries: [ + {binding: 0, resource: externalTexture}, + {binding: 1, resource: this.sampler}, + {binding: 2, resource: {buffer: this.inputBuffer}}, + ], + }); + const preprocessEncoder = this.device.createCommandEncoder({label: 'camera-preprocess'}); + drawRenderPass( + beginRenderPass(preprocessEncoder, this.preprocessTargetView), + this.pipelines.preprocess, + preprocessBindGroup, + ); + this.device.queue.submit([preprocessEncoder.finish()]); + } + + private async executeSegmentation(): Promise { + const outputs = await this.session.run({[SEG_INPUT_NAME]: this.inputTensor}); + const failures: Array = []; + try { + const inferenceOutput = outputs[SEG_OUTPUT_NAME]; + if (inferenceOutput == null) { + throw new MissingSegmentationAlphasOutputError(); + } + const output = requireGPUMask(inferenceOutput); + this.maskParamsView.setUint32(0, GPUBooleanFlag(this.maskPrimed), true); + this.device.queue.writeBuffer(this.maskParamsBuffer, 0, this.maskParams); + const maskBindGroup = this.device.createBindGroup({ + label: 'camera-mask-frame', + layout: this.pipelines.maskLayout, + entries: [ + {binding: 0, resource: {buffer: output.gpuBuffer}}, + {binding: 1, resource: {buffer: this.smoothedMaskBuffer}}, + {binding: 2, resource: this.maskTextureView}, + {binding: 3, resource: {buffer: this.maskParamsBuffer}}, + ], + }); + const maskEncoder = this.device.createCommandEncoder({label: 'camera-mask'}); + const pass = maskEncoder.beginComputePass(); + pass.setPipeline(this.pipelines.mask); + pass.setBindGroup(0, maskBindGroup); + pass.dispatchWorkgroups(SEG_INPUT_EDGE / 8, SEG_INPUT_EDGE / 8); + pass.end(); + this.device.queue.submit([maskEncoder.finish()]); + await this.device.queue.onSubmittedWorkDone(); + } catch (error) { + failures.push(error); + } + failures.push(...collectInferenceOutputDisposalFailures(outputs)); + throwCollectedFailures({failures, message: 'WebGPU camera segmentation failed'}); + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUShaders.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUShaders.ts new file mode 100644 index 000000000..a5c08a813 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWebGPUShaders.ts @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + WEB_CAMERA_EFFECT_MASK_BAND_EPSILON, + WEB_CAMERA_EFFECT_MASK_BAND_WIDTH, + WEB_CAMERA_EFFECT_MASK_CORE_GROW_MIN, + WEB_CAMERA_EFFECT_MASK_CORE_MIN, + WEB_CAMERA_EFFECT_MASK_EDGE_SOFTNESS, + WEB_CAMERA_EFFECT_MASK_GUIDE_RANGE_FALLOFF, + WEB_CAMERA_EFFECT_MASK_GUIDE_SPATIAL_FALLOFF, + WEB_CAMERA_EFFECT_MASK_HOLE_NEIGHBOUR_MIN, + WEB_CAMERA_EFFECT_MASK_SPECKLE_NEIGHBOUR_MAX, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_KEEP_STILL, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_HIGH, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW, + WEB_CAMERA_EFFECT_MASK_VOID_MAX, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectMask'; + +const FULLSCREEN_VERTEX = ` +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { + let positions = array( + vec2f(-1.0, -1.0), + vec2f(3.0, -1.0), + vec2f(-1.0, 3.0), + ); + let position = positions[vertexIndex]; + var output: VertexOutput; + output.position = vec4f(position, 0.0, 1.0); + output.uv = vec2f((position.x + 1.0) * 0.5, (1.0 - position.y) * 0.5); + return output; +} +`; + +export const CAMERA_SOURCE_SHADER = ` +${FULLSCREEN_VERTEX} + +@group(0) @binding(0) var source: texture_external; +@group(0) @binding(1) var sourceSampler: sampler; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + return textureSampleBaseClampToEdge(source, sourceSampler, input.uv); +} +`; + +export const CAMERA_PREPROCESS_SHADER = ` +${FULLSCREEN_VERTEX} + +@group(0) @binding(0) var source: texture_external; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var tensor: array; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + let tapOffset = 0.25 / 256.0; + var colourTotal = textureSampleBaseClampToEdge(source, sourceSampler, input.uv + vec2f(-tapOffset, -tapOffset)); + colourTotal += textureSampleBaseClampToEdge(source, sourceSampler, input.uv + vec2f(tapOffset, -tapOffset)); + colourTotal += textureSampleBaseClampToEdge(source, sourceSampler, input.uv + vec2f(-tapOffset, tapOffset)); + colourTotal += textureSampleBaseClampToEdge(source, sourceSampler, input.uv + vec2f(tapOffset, tapOffset)); + let colour = colourTotal * 0.25; + let position = vec2u(input.position.xy); + let index = position.y * 256u + position.x; + tensor[index] = colour.r; + tensor[65536u + index] = colour.g; + tensor[131072u + index] = colour.b; + return vec4f(0.0); +} +`; + +export const CAMERA_BLUR_SHADER = ` +${FULLSCREEN_VERTEX} + +struct BlurParams { + direction: vec2f, + radius: f32, + padding: f32, +} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var params: BlurParams; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + let scale = max(1.0, params.radius * 0.25); + let nearOffset = params.direction * 1.3846153846 * scale; + let farOffset = params.direction * 3.2307692308 * scale; + var colour = textureSample(source, sourceSampler, input.uv) * 0.2270270270; + colour += textureSample(source, sourceSampler, input.uv + nearOffset) * 0.3162162162; + colour += textureSample(source, sourceSampler, input.uv - nearOffset) * 0.3162162162; + colour += textureSample(source, sourceSampler, input.uv + farOffset) * 0.0702702703; + colour += textureSample(source, sourceSampler, input.uv - farOffset) * 0.0702702703; + return colour; +} +`; + +export const CAMERA_COPY_SHADER = ` +${FULLSCREEN_VERTEX} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + return textureSample(source, sourceSampler, input.uv); +} +`; + +export const CAMERA_COVER_SHADER = ` +${FULLSCREEN_VERTEX} + +struct CoverParams { + scale: vec2f, + offset: vec2f, +} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@group(0) @binding(2) var params: CoverParams; + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + return textureSample(source, sourceSampler, input.uv * params.scale + params.offset); +} +`; + +export const CAMERA_COMPOSITE_SHADER = ` +${FULLSCREEN_VERTEX} + +@group(0) @binding(0) var foreground: texture_2d; +@group(0) @binding(1) var background: texture_2d; +@group(0) @binding(2) var mask: texture_2d; +@group(0) @binding(3) var linearSampler: sampler; + +fn luminance(colour: vec3f) -> f32 { + return dot(colour, vec3f(0.2126, 0.7152, 0.0722)); +} + +fn refinedMask(uv: vec2f, guide: f32) -> f32 { + let maskSize = vec2f(256.0); + let maskPosition = uv * maskSize - vec2f(0.5); + let base = round(maskPosition); + var maskTotal = 0.0; + var weightTotal = 0.0; + for (var y: i32 = -1; y <= 1; y += 1) { + for (var x: i32 = -1; x <= 1; x += 1) { + let offset = vec2f(f32(x), f32(y)); + let sampleUv = clamp((base + offset + vec2f(0.5)) / maskSize, vec2f(0.0), vec2f(1.0)); + let sampleGuide = luminance(textureSampleLevel(foreground, linearSampler, sampleUv, 0.0).rgb); + let sampleDistance = maskPosition - (base + offset); + let spatialWeight = exp(-f32(${WEB_CAMERA_EFFECT_MASK_GUIDE_SPATIAL_FALLOFF}) * dot(sampleDistance, sampleDistance)); + let rangeWeight = exp(-f32(${WEB_CAMERA_EFFECT_MASK_GUIDE_RANGE_FALLOFF}) * abs(guide - sampleGuide)); + let weight = spatialWeight * rangeWeight; + maskTotal += textureSampleLevel(mask, linearSampler, sampleUv, 0.0).r * weight; + weightTotal += weight; + } + } + return maskTotal / max(weightTotal, 0.0001); +} + +@fragment +fn fragmentMain(input: VertexOutput) -> @location(0) vec4f { + let foregroundColour = textureSample(foreground, linearSampler, input.uv); + let backgroundColour = textureSample(background, linearSampler, input.uv); + let coarse = textureSampleLevel(mask, linearSampler, input.uv, 0.0).r; + var alpha = step(0.5, coarse); + if (coarse > ${WEB_CAMERA_EFFECT_MASK_BAND_EPSILON} && coarse < 1.0 - ${WEB_CAMERA_EFFECT_MASK_BAND_EPSILON}) { + let refined = refinedMask(input.uv, luminance(foregroundColour.rgb)); + let curve = clamp((refined - 0.5) / (2.0 * ${WEB_CAMERA_EFFECT_MASK_EDGE_SOFTNESS}) + 0.5, 0.0, 1.0); + alpha = curve * curve * (3.0 - 2.0 * curve); + } + return vec4f(mix(backgroundColour.rgb, foregroundColour.rgb, alpha), 1.0); +} +`; + +export const CAMERA_MASK_SHADER = ` +struct MaskParams { + primed: u32, + padding2: f32, + padding0: u32, + padding1: u32, +} + +@group(0) @binding(0) var inferenceMask: array; +@group(0) @binding(1) var smoothedMask: array; +@group(0) @binding(2) var outputMask: texture_storage_2d; +@group(0) @binding(3) var params: MaskParams; + +fn inferenceMaskAt(x: i32, y: i32) -> f32 { + let clampedX = u32(clamp(x, 0, 255)); + let clampedY = u32(clamp(y, 0, 255)); + return clamp(inferenceMask[clampedY * 256u + clampedX], 0.0, 1.0); +} + +@compute @workgroup_size(8, 8) +fn computeMain(@builtin(global_invocation_id) id: vec3u) { + if (id.x >= 256u || id.y >= 256u) { + return; + } + let index = id.y * 256u + id.x; + let texelX = i32(id.x); + let texelY = i32(id.y); + let centre = clamp(inferenceMask[index], 0.0, 1.0); + var neighbourhoodSum = 0.0; + var maxNeighbour = 0.0; + for (var offsetY: i32 = -1; offsetY <= 1; offsetY += 1) { + for (var offsetX: i32 = -1; offsetX <= 1; offsetX += 1) { + let neighbourValue = inferenceMaskAt(texelX + offsetX, texelY + offsetY); + neighbourhoodSum += neighbourValue; + if (offsetX != 0 || offsetY != 0) { + maxNeighbour = max(maxNeighbour, neighbourValue); + } + } + } + let neighbourhoodMean = neighbourhoodSum / 9.0; + var clean = centre; + if (centre >= ${WEB_CAMERA_EFFECT_MASK_CORE_MIN} && neighbourhoodMean < ${WEB_CAMERA_EFFECT_MASK_SPECKLE_NEIGHBOUR_MAX}) { + clean = 0.0; + } + if (centre <= ${WEB_CAMERA_EFFECT_MASK_VOID_MAX} && neighbourhoodMean > ${WEB_CAMERA_EFFECT_MASK_HOLE_NEIGHBOUR_MIN}) { + clean = 1.0; + } + if (centre >= ${WEB_CAMERA_EFFECT_MASK_CORE_GROW_MIN} && maxNeighbour >= ${WEB_CAMERA_EFFECT_MASK_CORE_MIN}) { + clean = 1.0; + } + let normalized = clamp((clean - ${WEB_CAMERA_EFFECT_MASK_VOID_MAX}) / ${WEB_CAMERA_EFFECT_MASK_BAND_WIDTH}, 0.0, 1.0); + let shaped = normalized * normalized * (3.0 - 2.0 * normalized); + let previous = smoothedMask[index]; + let delta = abs(shaped - previous); + let rawMotion = clamp((delta - ${WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW}) / (${WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_HIGH} - ${WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW}), 0.0, 1.0); + let motion = rawMotion * rawMotion * (3.0 - 2.0 * rawMotion); + let keep = select(0.0, ${WEB_CAMERA_EFFECT_MASK_TEMPORAL_KEEP_STILL} * (1.0 - motion), params.primed != 0u); + var next = keep * previous + (1.0 - keep) * shaped; + if (clean >= ${WEB_CAMERA_EFFECT_MASK_CORE_MIN}) { + next = 1.0; + } + if (clean <= ${WEB_CAMERA_EFFECT_MASK_VOID_MAX}) { + next = 0.0; + } + smoothedMask[index] = next; + textureStore(outputMask, vec2u(id.xy), vec4f(next, 0.0, 0.0, 1.0)); +} +`; diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWorker.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWorker.ts new file mode 100644 index 000000000..e5250f605 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebCameraEffectWorker.ts @@ -0,0 +1,611 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {CameraBackgroundMode} from '@app/features/voice/utils/camera-effects/CameraCaptureContract'; +import { + type ErrorDiagnosticType, + getErrorDiagnostic, + getErrorDiagnosticType, +} from '@app/features/voice/utils/camera-effects/ErrorDiagnostic'; +import {WebCameraEffectCanvasRenderer} from '@app/features/voice/utils/camera-effects/WebCameraEffectCanvasRenderer'; +import { + createWebCameraEffectVideoFrameSource, + loadWebCameraEffectCustomFrameSource, + type WebCameraEffectCustomFrameSource, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectCustomImage'; +import { + WEB_CAMERA_EFFECT_STOP_GRACE_MS, + WebCameraEffectCommandKind, + WebCameraEffectCommandPolicy, + WebCameraEffectCustomMediaKind, + WebCameraEffectEventKind, + WebCameraEffectShutdownReason, + type WebCameraEffectStartCommand, + type WebCameraEffectUpdateCommand, + type WebCameraEffectWorkerEvent, + type WebCameraPipelineConfig, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectProtocol'; +import type {WebCameraEffectRenderer} from '@app/features/voice/utils/camera-effects/WebCameraEffectRenderer'; +import {WebCameraEffectWebGPURenderer} from '@app/features/voice/utils/camera-effects/WebCameraEffectWebGPURenderer'; + +type CameraEffectWorkerScope = DedicatedWorkerGlobalScope & {close(): void}; + +function requireCameraEffectWorkerScope(value: unknown): asserts value is CameraEffectWorkerScope { + if (typeof value !== 'object' || value == null) { + throw new Error('Camera effect worker global scope is unavailable'); + } + if (!('postMessage' in value) || typeof value.postMessage !== 'function') { + throw new Error('Camera effect worker global scope cannot post messages'); + } + if (!('addEventListener' in value) || typeof value.addEventListener !== 'function') { + throw new Error('Camera effect worker global scope cannot receive messages'); + } + if (!('close' in value) || typeof value.close !== 'function') { + throw new Error('Camera effect worker global scope cannot be closed'); + } +} + +requireCameraEffectWorkerScope(self); +const workerScope = self; +__webpack_base_uri__ = workerScope.location.href; +const OPERATION_TIMEOUT_MS = 10_000; +const OPERATION_QUEUE_MAX = 8; +const DISPOSAL_TIMEOUT_MS = 1_500; + +const WebCameraEffectLifecycle = Object.freeze({ + RUNNING: 'running', + DRAINING: 'draining', + STOPPING: 'stopping', + CLOSED: 'closed', +} as const); + +type WebCameraEffectLifecycle = (typeof WebCameraEffectLifecycle)[keyof typeof WebCameraEffectLifecycle]; + +function postEvent(event: WebCameraEffectWorkerEvent): void { + workerScope.postMessage(event); +} + +function postFailure(error: unknown): void { + postEvent({kind: WebCameraEffectEventKind.FAILED, ...getErrorDiagnostic(error)}); +} + +function rejectCommand(error: unknown): void { + try { + postFailure(error); + } finally { + workerScope.close(); + } +} + +class CameraEffectInputStreamEndedError extends Error { + constructor() { + super('Camera effect input stream ended'); + this.name = 'CameraEffectInputStreamEndedError'; + } +} + +class CameraEffectDisposalTimeoutError extends Error { + constructor() { + super('Camera effect worker cleanup exceeded its deadline'); + this.name = 'CameraEffectDisposalTimeoutError'; + } +} + +interface RendererSelection { + readonly renderer: WebCameraEffectRenderer; + readonly fallbackErrorType: ErrorDiagnosticType | null; +} + +function hasSameCustomBackground(current: WebCameraPipelineConfig, next: WebCameraPipelineConfig): boolean { + const currentBackground = current.background; + const nextBackground = next.background; + if (currentBackground == null || nextBackground == null) { + return false; + } + if (currentBackground.mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + if (nextBackground.mode !== CameraBackgroundMode.CUSTOM) { + return false; + } + if (currentBackground.customMediaURL !== nextBackground.customMediaURL) { + return false; + } + return currentBackground.customMediaKind === nextBackground.customMediaKind; +} + +async function createCustomFrameSource( + config: WebCameraPipelineConfig, + videoFrames: ReadableStream | null, +): Promise { + const background = config.background; + if (background == null || background.mode !== CameraBackgroundMode.CUSTOM) { + return null; + } + let source: WebCameraEffectCustomFrameSource; + if (background.customMediaKind === WebCameraEffectCustomMediaKind.VIDEO) { + if (videoFrames == null) { + throw new Error('Video camera background update requires a transferred frame stream'); + } + source = await createWebCameraEffectVideoFrameSource(videoFrames); + } else { + source = await loadWebCameraEffectCustomFrameSource(background.customMediaURL); + } + const decodedKindMatches = source.kind === background.customMediaKind; + const singleFrameAnimation = + background.customMediaKind === WebCameraEffectCustomMediaKind.ANIMATED && + source.kind === WebCameraEffectCustomMediaKind.STATIC; + if (decodedKindMatches || singleFrameAnimation) { + return source; + } + let cleanupError: unknown; + try { + await source.dispose(); + } catch (error) { + cleanupError = error; + } + const mismatchError = new Error('Custom camera background media kind does not match its decoded content'); + if (cleanupError !== undefined) { + throw new AggregateError([mismatchError, cleanupError], 'Custom camera background rejection cleanup failed'); + } + throw mismatchError; +} + +async function selectRenderer( + command: WebCameraEffectStartCommand, + customFrameSource: WebCameraEffectCustomFrameSource | null, +): Promise { + if (command.config.background == null) { + return { + renderer: await WebCameraEffectCanvasRenderer.create(command.fallbackCanvas, command.config, customFrameSource), + fallbackErrorType: null, + }; + } + if (!command.preferWebGPU) { + return { + renderer: await WebCameraEffectCanvasRenderer.create(command.fallbackCanvas, command.config, customFrameSource), + fallbackErrorType: null, + }; + } + try { + return { + renderer: await WebCameraEffectWebGPURenderer.create(command.gpuCanvas, command.config, customFrameSource), + fallbackErrorType: null, + }; + } catch (webGPUError) { + try { + return { + renderer: await WebCameraEffectCanvasRenderer.create(command.fallbackCanvas, command.config, customFrameSource), + fallbackErrorType: getErrorDiagnosticType(webGPUError), + }; + } catch (WASMError) { + throw new AggregateError([webGPUError, WASMError], 'Every camera effect renderer failed'); + } + } +} + +class WebCameraEffectWorkerController { + private readonly reader: ReadableStreamDefaultReader; + private operationTail: Promise = Promise.resolve(); + private updatePreparationPromise: Promise = Promise.resolve(); + private disposePromise: Promise | null = null; + private pendingOperations = 0; + private updateInProgress = false; + private lifecycle: WebCameraEffectLifecycle = WebCameraEffectLifecycle.RUNNING; + private terminalReason: WebCameraEffectShutdownReason | null = null; + private terminalDiagnosticError: unknown = null; + private resolveOwnerStopIntent: (() => void) | null = null; + private readonly ownerStopIntent: Promise; + + constructor( + private readonly renderer: WebCameraEffectRenderer, + readable: ReadableStream, + private config: WebCameraPipelineConfig, + private customFrameSource: WebCameraEffectCustomFrameSource | null, + ) { + this.reader = readable.getReader(); + let capturedResolveOwnerStopIntent: (() => void) | null = null; + this.ownerStopIntent = new Promise((resolve) => { + capturedResolveOwnerStopIntent = resolve; + }); + const resolveOwnerStopIntent = capturedResolveOwnerStopIntent; + if (resolveOwnerStopIntent == null) { + throw new Error('Camera effect worker stop intent resolver was not captured'); + } + this.resolveOwnerStopIntent = resolveOwnerStopIntent; + } + + private isShuttingDown(): boolean { + return this.lifecycle !== WebCameraEffectLifecycle.RUNNING; + } + + run(): void { + this.pump().catch((error) => { + this.fail(error); + }); + } + + update(command: WebCameraEffectUpdateCommand): void { + if (this.isShuttingDown()) { + return; + } + if (this.updateInProgress) { + postEvent({ + kind: WebCameraEffectEventKind.UPDATE_FAILED, + requestId: command.requestId, + ...getErrorDiagnostic(new Error('Camera effect worker already has an update in progress')), + }); + return; + } + this.updateInProgress = true; + const preparation = this.prepareUpdate(command).finally(() => { + this.updateInProgress = false; + }); + this.updatePreparationPromise = preparation; + preparation.catch((error) => { + this.fail(error); + }); + } + + private async prepareUpdate(command: WebCameraEffectUpdateCommand): Promise { + let nextCustomFrameSource = this.customFrameSource; + let ownsCandidate = false; + try { + if (!hasSameCustomBackground(this.config, command.config)) { + nextCustomFrameSource = await createCustomFrameSource(command.config, command.customBackgroundFrames); + ownsCandidate = nextCustomFrameSource != null; + } + } catch (error) { + const diagnosticError = await this.disposeRejectedCandidate(error, nextCustomFrameSource, ownsCandidate); + this.postUpdateFailure(command.requestId, diagnosticError); + return; + } + if (this.isShuttingDown()) { + await this.disposeStoppedCandidate(nextCustomFrameSource, ownsCandidate); + return; + } + let configurationError: unknown; + let configurationFailed = false; + let skipped = false; + try { + await this.enqueue(async () => { + if (this.isShuttingDown()) { + skipped = true; + return; + } + try { + await this.renderer.configure(command.config, nextCustomFrameSource); + } catch (error) { + configurationError = error; + configurationFailed = true; + } + }, false); + } catch (error) { + const diagnosticError = await this.disposeRejectedCandidate(error, nextCustomFrameSource, ownsCandidate); + this.postUpdateFailure(command.requestId, diagnosticError); + return; + } + if (skipped) { + await this.disposeStoppedCandidate(nextCustomFrameSource, ownsCandidate); + return; + } + if (configurationFailed) { + const diagnosticError = await this.disposeRejectedCandidate( + configurationError, + nextCustomFrameSource, + ownsCandidate, + ); + this.postUpdateFailure(command.requestId, diagnosticError); + return; + } + const previousCustomFrameSource = this.customFrameSource; + this.config = command.config; + this.customFrameSource = nextCustomFrameSource; + if (!this.isShuttingDown()) { + postEvent({kind: WebCameraEffectEventKind.UPDATED, requestId: command.requestId}); + } + if (previousCustomFrameSource !== nextCustomFrameSource) { + try { + await previousCustomFrameSource?.dispose(); + } catch (error) { + throw new Error('Camera effect update committed but previous custom source cleanup failed', {cause: error}); + } + } + } + + private postUpdateFailure(requestId: number, error: unknown): void { + if (this.isShuttingDown()) { + return; + } + postEvent({ + kind: WebCameraEffectEventKind.UPDATE_FAILED, + requestId, + ...getErrorDiagnostic(error), + }); + } + + private async disposeStoppedCandidate( + candidate: WebCameraEffectCustomFrameSource | null, + ownsCandidate: boolean, + ): Promise { + if (!ownsCandidate || candidate == null) { + return; + } + await candidate.dispose(); + } + + private async disposeRejectedCandidate( + primaryError: unknown, + candidate: WebCameraEffectCustomFrameSource | null, + ownsCandidate: boolean, + ): Promise { + if (!ownsCandidate || candidate == null) { + return primaryError; + } + try { + await candidate.dispose(); + return primaryError; + } catch (cleanupError) { + return new AggregateError([primaryError, cleanupError], 'Camera effect update and candidate cleanup both failed'); + } + } + + stop(): void { + if (this.lifecycle === WebCameraEffectLifecycle.STOPPING || this.lifecycle === WebCameraEffectLifecycle.CLOSED) { + this.resolveOwnerStopIntent?.(); + return; + } + if (this.terminalReason == null) { + this.terminalReason = WebCameraEffectShutdownReason.OWNER_STOP; + } + this.lifecycle = WebCameraEffectLifecycle.STOPPING; + this.resolveOwnerStopIntent?.(); + this.dispose().catch(() => { + workerScope.close(); + }); + } + + private async pump(): Promise { + while (this.lifecycle === WebCameraEffectLifecycle.RUNNING) { + const {value, done} = await this.reader.read(); + if (done || value == null) { + await this.settleStreamEnd(); + break; + } + try { + await this.enqueue(() => this.renderer.render(value, performance.now())); + } finally { + value.close(); + } + } + await this.dispose(); + } + + private async settleStreamEnd(): Promise { + if (this.isShuttingDown()) { + return; + } + this.lifecycle = WebCameraEffectLifecycle.DRAINING; + if (await this.awaitOwnerStopIntent()) { + return; + } + throw new CameraEffectInputStreamEndedError(); + } + + private awaitOwnerStopIntent(): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, WEB_CAMERA_EFFECT_STOP_GRACE_MS); + void this.ownerStopIntent.then(() => { + clearTimeout(timeout); + resolve(true); + }); + }); + } + + private enqueue(operation: () => Promise, enforceDeadline = true): Promise { + if (this.pendingOperations >= OPERATION_QUEUE_MAX) { + return Promise.reject(new Error('Camera effect worker operation queue is full')); + } + this.pendingOperations += 1; + const result = this.operationTail.then(() => this.executeOperation(operation, enforceDeadline)); + const trackedResult = result.finally(() => { + this.pendingOperations -= 1; + }); + this.operationTail = trackedResult.catch((error) => { + this.fail(error); + }); + return trackedResult; + } + + private async executeOperation(operation: () => Promise, enforceDeadline: boolean): Promise { + if (!enforceDeadline) { + await operation(); + return; + } + const timeout = setTimeout(() => { + this.fail(new Error('Camera effect worker operation exceeded its deadline')); + }, OPERATION_TIMEOUT_MS); + try { + await operation(); + } finally { + clearTimeout(timeout); + } + } + + private fail(error: unknown): void { + if (this.terminalReason != null) { + return; + } + this.terminalReason = + error instanceof CameraEffectInputStreamEndedError + ? WebCameraEffectShutdownReason.INPUT_ENDED + : WebCameraEffectShutdownReason.OPERATION_FAILED; + try { + postFailure(error); + } finally { + this.stop(); + } + } + + private dispose(): Promise { + if (this.disposePromise == null) { + const updateSettlement = this.updatePreparationPromise.then( + () => [] as Array, + (error: unknown) => [error], + ); + const resourceDisposal = updateSettlement.then(async (preparationFailures) => { + await this.operationTail; + const outcomes = await Promise.allSettled([ + this.renderer.dispose(), + Promise.resolve().then(() => this.customFrameSource?.dispose()), + ]); + const failures = [ + ...preparationFailures, + ...outcomes.flatMap((outcome) => (outcome.status === 'rejected' ? [outcome.reason] : [])), + ]; + if (failures.length > 0) { + throw this.resolveDisposalError(failures); + } + }); + this.disposePromise = Promise.allSettled([this.reader.cancel(), this.withDisposalDeadline(resourceDisposal)]) + .then((outcomes) => this.reportDisposalFailures(outcomes)) + .finally(() => { + this.lifecycle = WebCameraEffectLifecycle.CLOSED; + try { + postEvent({ + kind: WebCameraEffectEventKind.STOPPED, + reason: this.terminalReason ?? WebCameraEffectShutdownReason.OWNER_STOP, + diagnostic: + this.terminalDiagnosticError == null ? null : getErrorDiagnostic(this.terminalDiagnosticError), + }); + } finally { + workerScope.close(); + } + }); + } + return this.disposePromise; + } + + private withDisposalDeadline(disposal: Promise): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new CameraEffectDisposalTimeoutError()); + }, DISPOSAL_TIMEOUT_MS); + disposal.then( + () => { + clearTimeout(timeout); + resolve(); + }, + (error: unknown) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); + } + + private reportDisposalFailures(outcomes: ReadonlyArray>): void { + const failures = outcomes + .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') + .map((outcome) => outcome.reason); + if (failures.length === 0) { + return; + } + const error = this.resolveDisposalError(failures); + if (this.terminalReason === WebCameraEffectShutdownReason.OWNER_STOP) { + this.terminalReason = WebCameraEffectShutdownReason.CLEANUP_FAILED; + this.terminalDiagnosticError = error; + return; + } + postFailure(error); + } + + private resolveDisposalError(failures: ReadonlyArray): unknown { + if (failures.length === 1) { + return failures[0]; + } + return new AggregateError(failures, 'Camera effect worker cleanup failed'); + } +} + +class WebCameraEffectWorkerEntry { + private controller: WebCameraEffectWorkerController | null = null; + private starting = false; + + install(): void { + workerScope.addEventListener('message', (event: MessageEvent) => { + this.handle(event.data); + }); + } + + handle(data: unknown): void { + if (data == null || typeof data !== 'object') { + rejectCommand(new Error('Camera effect worker received a non-object command')); + return; + } + if (!WebCameraEffectCommandPolicy.isValid(data)) { + rejectCommand(new Error('Camera effect worker received an unknown command')); + return; + } + if (data.kind === WebCameraEffectCommandKind.START) { + this.start(data).catch((error) => { + rejectCommand(error); + }); + return; + } + if (data.kind === WebCameraEffectCommandKind.UPDATE) { + if (this.controller == null) { + rejectCommand(new Error('Camera effect worker received update before start')); + return; + } + this.controller.update(data); + return; + } + if (data.kind === WebCameraEffectCommandKind.STOP) { + if (this.controller == null) { + rejectCommand(new Error('Camera effect worker received stop before start')); + return; + } + this.controller.stop(); + } + } + + private async start(command: WebCameraEffectStartCommand): Promise { + if (this.starting || this.controller != null) { + throw new Error('Camera effect worker received more than one start command'); + } + this.starting = true; + const customFrameSource = await createCustomFrameSource(command.config, command.customBackgroundFrames); + let selection: RendererSelection; + try { + selection = await selectRenderer(command, customFrameSource); + } catch (error) { + let cleanupError: unknown; + try { + await customFrameSource?.dispose(); + } catch (caughtCleanupError) { + cleanupError = caughtCleanupError; + } + if (cleanupError !== undefined) { + throw new AggregateError([error, cleanupError], 'Camera effect renderer selection cleanup failed'); + } + throw error; + } + const nextController = new WebCameraEffectWorkerController( + selection.renderer, + command.readable, + command.config, + customFrameSource, + ); + this.controller = nextController; + postEvent({ + kind: WebCameraEffectEventKind.READY, + backend: selection.renderer.backend, + fallbackErrorType: selection.fallbackErrorType, + }); + nextController.run(); + } +} + +new WebCameraEffectWorkerEntry().install(); diff --git a/fluxer_app/src/features/voice/utils/camera-effects/WebSelfieSegmenter.ts b/fluxer_app/src/features/voice/utils/camera-effects/WebSelfieSegmenter.ts new file mode 100644 index 000000000..08a1c4646 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/WebSelfieSegmenter.ts @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {resolveWorkerAssetUrl} from '@app/features/platform/utils/WorkerAssetUrl'; +import { + collectSettledFailures, + throwCollectedFailures, +} from '@app/features/voice/utils/camera-effects/AggregateOperations'; +import { + cancelResponseBodyAndThrow, + readBoundedResponseArrayBuffer, + runWithResponseDeadline, +} from '@app/features/voice/utils/camera-effects/BoundedResponse'; +import { + shapeWebCameraEffectMaskAlpha, + WEB_CAMERA_EFFECT_MASK_CORE_GROW_MIN, + WEB_CAMERA_EFFECT_MASK_CORE_MIN, + WEB_CAMERA_EFFECT_MASK_HOLE_NEIGHBOUR_MIN, + WEB_CAMERA_EFFECT_MASK_SPECKLE_NEIGHBOUR_MAX, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_KEEP_STILL, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_HIGH, + WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW, + WEB_CAMERA_EFFECT_MASK_VOID_MAX, +} from '@app/features/voice/utils/camera-effects/WebCameraEffectMask'; +import ortWasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.asyncify.wasm'; +import type * as OrtNamespace from 'onnxruntime-web/webgpu'; +import modelAssetUrl from './models/selfie_segmentation_256x256.onnx'; + +export class MissingSegmentationAlphasOutputError extends Error { + constructor() { + super('Segmentation model produced no alphas output'); + this.name = 'MissingSegmentationAlphasOutputError'; + } +} + +export const SEG_INPUT_EDGE = 256; +export const SEG_INPUT_NAME = 'pixel_values'; +export const SEG_OUTPUT_NAME = 'alphas'; +export const SEG_INPUT_PIXELS = SEG_INPUT_EDGE * SEG_INPUT_EDGE; + +const MODEL_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; +const MODEL_RESPONSE_MAX_CHUNKS = 2048; +const MODEL_REQUEST_TIMEOUT_MS = 30_000; + +export type WebSelfieOrtModule = typeof OrtNamespace; + +export interface WebSelfieRuntime { + readonly ort: WebSelfieOrtModule; + readonly modelBytes: Uint8Array; +} + +class WebSelfieRuntimeAssetOwner { + private ortModule: Promise | null = null; + private modelBytes: Promise | null = null; + + loadOrt(): Promise { + if (this.ortModule == null) { + const loading = import('onnxruntime-web/webgpu').then((ort) => this.configureOrt(ort)); + const cached = loading.catch((error) => { + this.clearFailedOrtLoad(cached); + throw error; + }); + this.ortModule = cached; + } + return this.ortModule; + } + + private configureOrt(ort: WebSelfieOrtModule): WebSelfieOrtModule { + ort.env.wasm.wasmPaths = {wasm: resolveWorkerAssetUrl(ortWasmUrl)}; + ort.env.wasm.proxy = false; + ort.env.wasm.numThreads = resolveOrtThreadCount(); + ort.env.logLevel = 'error'; + return ort; + } + + private clearFailedOrtLoad(cached: Promise): void { + if (this.ortModule === cached) { + this.ortModule = null; + } + } + + loadModel(): Promise { + if (this.modelBytes == null) { + const loading = runWithResponseDeadline({ + timeoutMilliseconds: MODEL_REQUEST_TIMEOUT_MS, + description: 'Web selfie segmentation model request', + signal: null, + operation: async (signal) => { + let response: Response; + try { + response = await fetch(resolveWorkerAssetUrl(modelAssetUrl), { + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + signal, + }); + } catch { + if (signal.aborted) throw signal.reason; + throw new Error('Web selfie segmentation model request failed'); + } + if (!response.ok) { + await cancelResponseBodyAndThrow({ + response, + error: new Error(`Segmentation model request failed with status ${response.status}`), + description: 'Web selfie segmentation model response', + }); + } + return readBoundedResponseArrayBuffer({ + response, + maximumBytes: MODEL_RESPONSE_MAX_BYTES, + maximumChunks: MODEL_RESPONSE_MAX_CHUNKS, + description: 'Web selfie segmentation model response', + }); + }, + }).then((bytes) => new Uint8Array(bytes)); + const cached = loading.catch((error) => { + this.clearFailedModelLoad(cached); + throw error; + }); + this.modelBytes = cached; + } + return this.modelBytes; + } + + private clearFailedModelLoad(cached: Promise): void { + if (this.modelBytes === cached) { + this.modelBytes = null; + } + } +} + +const webSelfieRuntimeAssetOwner = new WebSelfieRuntimeAssetOwner(); + +async function loadWebSelfieOrt(): Promise { + return webSelfieRuntimeAssetOwner.loadOrt(); +} + +async function loadWebSelfieModel(): Promise { + return webSelfieRuntimeAssetOwner.loadModel(); +} + +function resolveOrtThreadCount(): number { + if (!globalThis.crossOriginIsolated) { + return 1; + } + if (!('navigator' in globalThis)) { + return 1; + } + const hardwareConcurrency = navigator.hardwareConcurrency; + if (!Number.isSafeInteger(hardwareConcurrency)) { + return 1; + } + if (hardwareConcurrency < 1) { + return 1; + } + return Math.min(4, hardwareConcurrency); +} + +export async function loadWebSelfieRuntime(): Promise { + const [ortOutcome, modelBytesOutcome] = await Promise.allSettled([loadWebSelfieOrt(), loadWebSelfieModel()]); + const failures: Array = []; + if (ortOutcome.status === 'rejected') failures.push(ortOutcome.reason); + if (modelBytesOutcome.status === 'rejected') failures.push(modelBytesOutcome.reason); + throwCollectedFailures({failures, message: 'Web selfie runtime loading failed'}); + if (ortOutcome.status !== 'fulfilled' || modelBytesOutcome.status !== 'fulfilled') { + throw new Error('Web selfie runtime loading produced no result'); + } + return {ort: ortOutcome.value, modelBytes: modelBytesOutcome.value}; +} + +function requireFloatMask(output: OrtNamespace.Tensor): Float32Array { + const data = output.data; + if (!(data instanceof Float32Array) || data.length !== SEG_INPUT_PIXELS) { + throw new Error('Segmentation model produced an unexpected CPU output'); + } + return data; +} + +function collectInferenceOutputDisposalFailures( + outputs: Readonly>, +): ReadonlyArray { + const failures: Array = []; + for (const output of Object.values(outputs)) { + try { + output.dispose(); + } catch (error) { + failures.push(error); + } + } + return failures; +} + +export class WebSelfieSegmenter { + private readonly session: OrtNamespace.InferenceSession; + private readonly inputChw = new Float32Array(3 * SEG_INPUT_PIXELS); + private readonly inputTensor: OrtNamespace.Tensor; + private readonly previous = new Float32Array(SEG_INPUT_PIXELS); + private primed = false; + private disposed = false; + + private constructor(ort: WebSelfieOrtModule, session: OrtNamespace.InferenceSession) { + this.session = session; + this.inputTensor = new ort.Tensor('float32', this.inputChw, [1, 3, SEG_INPUT_EDGE, SEG_INPUT_EDGE]); + } + + static async create(): Promise { + const {ort, modelBytes} = await loadWebSelfieRuntime(); + const session = await ort.InferenceSession.create(modelBytes, { + executionProviders: ['wasm'], + graphOptimizationLevel: 'all', + logSeverityLevel: 3, + }); + const segmenter = new WebSelfieSegmenter(ort, session); + try { + await segmenter.warmup(); + return segmenter; + } catch (error) { + const cleanupFailures = await collectSettledFailures([segmenter.dispose()]); + throwCollectedFailures({ + failures: [error, ...cleanupFailures], + message: 'Web selfie segmenter initialization failed', + }); + } + } + + private async warmup(): Promise { + const outputs = await this.session.run({[SEG_INPUT_NAME]: this.inputTensor}); + const failures: Array = []; + try { + const output = outputs[SEG_OUTPUT_NAME]; + if (output == null) { + throw new MissingSegmentationAlphasOutputError(); + } + requireFloatMask(output); + } catch (error) { + failures.push(error); + } + failures.push(...collectInferenceOutputDisposalFailures(outputs)); + throwCollectedFailures({failures, message: 'Web selfie segmenter warm-up failed'}); + } + + private writeShapedMask(alphas: Float32Array, maskRGBA: Uint8ClampedArray): void { + const lastTexel = SEG_INPUT_EDGE - 1; + for (let y = 0; y < SEG_INPUT_EDGE; y += 1) { + for (let x = 0; x < SEG_INPUT_EDGE; x += 1) { + const index = y * SEG_INPUT_EDGE + x; + const centre = Math.max(0, Math.min(1, alphas[index])); + let neighbourhoodSum = 0; + let maxNeighbour = 0; + for (let offsetY = -1; offsetY <= 1; offsetY += 1) { + const sampleY = Math.max(0, Math.min(lastTexel, y + offsetY)); + for (let offsetX = -1; offsetX <= 1; offsetX += 1) { + const sampleX = Math.max(0, Math.min(lastTexel, x + offsetX)); + const sample = Math.max(0, Math.min(1, alphas[sampleY * SEG_INPUT_EDGE + sampleX])); + neighbourhoodSum += sample; + if (offsetX !== 0 || offsetY !== 0) { + maxNeighbour = Math.max(maxNeighbour, sample); + } + } + } + const neighbourhoodMean = neighbourhoodSum / 9; + let clean = centre; + if ( + centre >= WEB_CAMERA_EFFECT_MASK_CORE_MIN && + neighbourhoodMean < WEB_CAMERA_EFFECT_MASK_SPECKLE_NEIGHBOUR_MAX + ) { + clean = 0; + } + if ( + centre <= WEB_CAMERA_EFFECT_MASK_VOID_MAX && + neighbourhoodMean > WEB_CAMERA_EFFECT_MASK_HOLE_NEIGHBOUR_MIN + ) { + clean = 1; + } + if (centre >= WEB_CAMERA_EFFECT_MASK_CORE_GROW_MIN && maxNeighbour >= WEB_CAMERA_EFFECT_MASK_CORE_MIN) { + clean = 1; + } + const shaped = shapeWebCameraEffectMaskAlpha(clean); + const previous = this.previous[index]; + const delta = Math.abs(shaped - previous); + const rawMotion = Math.max( + 0, + Math.min( + 1, + (delta - WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW) / + (WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_HIGH - WEB_CAMERA_EFFECT_MASK_TEMPORAL_MOTION_LOW), + ), + ); + const motion = rawMotion * rawMotion * (3 - 2 * rawMotion); + const keep = this.primed ? WEB_CAMERA_EFFECT_MASK_TEMPORAL_KEEP_STILL * (1 - motion) : 0; + let next = keep * previous + (1 - keep) * shaped; + if (clean >= WEB_CAMERA_EFFECT_MASK_CORE_MIN) { + next = 1; + } + if (clean <= WEB_CAMERA_EFFECT_MASK_VOID_MAX) { + next = 0; + } + this.previous[index] = next; + const RGBAIndex = index * 4; + maskRGBA[RGBAIndex] = 0; + maskRGBA[RGBAIndex + 1] = 0; + maskRGBA[RGBAIndex + 2] = 0; + maskRGBA[RGBAIndex + 3] = Math.round(next * 255); + } + } + } + + async segmentIntoMask(RGBA: Uint8ClampedArray, maskRGBA: Uint8ClampedArray): Promise { + if (this.disposed) { + throw new Error('Cannot segment with a disposed web selfie segmenter'); + } + if (RGBA.length !== SEG_INPUT_PIXELS * 4) { + throw new Error('Segmentation input must be one 256 by 256 RGBA frame'); + } + if (maskRGBA.length !== SEG_INPUT_PIXELS * 4) { + throw new Error('Segmentation mask must hold RGBA for every model output sample'); + } + const green = SEG_INPUT_PIXELS; + const blue = SEG_INPUT_PIXELS * 2; + for (let pixel = 0; pixel < SEG_INPUT_PIXELS; pixel += 1) { + const source = pixel * 4; + this.inputChw[pixel] = RGBA[source] / 255; + this.inputChw[green + pixel] = RGBA[source + 1] / 255; + this.inputChw[blue + pixel] = RGBA[source + 2] / 255; + } + const outputs = await this.session.run({[SEG_INPUT_NAME]: this.inputTensor}); + const failures: Array = []; + try { + const output = outputs[SEG_OUTPUT_NAME]; + if (output == null) { + throw new MissingSegmentationAlphasOutputError(); + } + const alphas = requireFloatMask(output); + this.writeShapedMask(alphas, maskRGBA); + this.primed = true; + } catch (error) { + failures.push(error); + } + failures.push(...collectInferenceOutputDisposalFailures(outputs)); + throwCollectedFailures({failures, message: 'Web selfie segmentation failed'}); + } + + reset(): void { + this.previous.fill(0); + this.primed = false; + } + + async dispose(): Promise { + if (this.disposed) { + return; + } + this.disposed = true; + const inputFailures = await collectSettledFailures([Promise.resolve().then(() => this.inputTensor.dispose())]); + const sessionFailures = await collectSettledFailures([Promise.resolve().then(() => this.session.release())]); + throwCollectedFailures({ + failures: [...inputFailures, ...sessionFailures], + message: 'Web selfie segmenter teardown failed', + }); + } +} diff --git a/fluxer_app/src/features/voice/utils/camera-effects/models/NOTICE.txt b/fluxer_app/src/features/voice/utils/camera-effects/models/NOTICE.txt new file mode 100644 index 000000000..a4dca7d71 --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/models/NOTICE.txt @@ -0,0 +1,13 @@ +selfie_segmentation_256x256.onnx + +Source: https://huggingface.co/onnx-community/mediapipe_selfie_segmentation (onnx/model.onnx) +Upstream: Google MediaPipe Selfie Segmentation. +License: Apache License 2.0. +SHA-256: 3241ac4ad8aa35bdaf33946776db29f7c283a413aa0b0dacb9483594b4531aad + +Tensor interface: + input "pixel_values": float32 [batch, 3, 256, 256], NCHW, RGB, values rescaled to [0, 1] (x / 255), no mean/std normalization. + output "alphas": float32 [batch, 1, 256, 256], single-channel foreground probability mask in [0, 1]. + +A copy of the Apache License 2.0 is available at https://www.apache.org/licenses/LICENSE-2.0. +This model is redistributed unmodified under the terms of that license. diff --git a/fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.d.onnx.ts b/fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.d.onnx.ts new file mode 100644 index 000000000..611112b6c --- /dev/null +++ b/fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.d.onnx.ts @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +declare const url: string; +export default url; diff --git a/fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.onnx b/fluxer_app/src/features/voice/utils/camera-effects/models/selfie_segmentation_256x256.onnx new file mode 100644 index 0000000000000000000000000000000000000000..f926ab523dd51e832fb92b05325f33c4adfeabbf GIT binary patch literal 462352 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%0A5e=ulYgrnD+O(|A-E4VQD3PzK0>MtFGTuc8#8g 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

o}+h7w|D{DOj2g=I$!NN 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 PCk=v3_>67$v{XKg$OD1x?wop0 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 z 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 diff --git a/fluxer_app/tsconfig.json b/fluxer_app/tsconfig.json index c0bd1048d..8ddf765c0 100644 --- a/fluxer_app/tsconfig.json +++ b/fluxer_app/tsconfig.json @@ -15,6 +15,7 @@ "emitDecoratorMetadata": true, "paths": { "@app/*": ["./src/*"], + "onnxruntime-web/ort-wasm-simd-threaded.asyncify.wasm": ["./src/types/ort-wasm-asset.d.ts"], "@fluxer/*": ["../packages/*", "../packages/*/src/index.ts"], "@app_scripts/*": ["./scripts/*"], "@pkgs/*": ["./pkgs/*"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd62d5c5f..5a6fb9d35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ catalogs: '@vvo/tzdb': specifier: 6.198.0 version: 6.198.0 + '@webgpu/types': + specifier: 0.1.72 + version: 0.1.72 archiver: specifier: 7.0.1 version: 7.0.1 @@ -264,6 +267,12 @@ catalogs: nodemailer: specifier: 9.0.6 version: 9.0.6 + onnxruntime-common: + specifier: 1.27.0 + version: 1.27.0 + onnxruntime-web: + specifier: 1.27.0 + version: 1.27.0 pg: specifier: 8.16.3 version: 8.16.3 @@ -1526,6 +1535,12 @@ importers: motion: specifier: 'catalog:' version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + onnxruntime-common: + specifier: 'catalog:' + version: 1.27.0 + onnxruntime-web: + specifier: 'catalog:' + version: 1.27.0 qrcode: specifier: 'catalog:' version: 1.5.4 @@ -1629,6 +1644,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260224.1 + '@webgpu/types': + specifier: 'catalog:' + version: 0.1.72 autoprefixer: specifier: 'catalog:' version: 10.4.24(postcss@8.5.26) @@ -5676,6 +5694,33 @@ packages: '@preact/signals-core@1.14.4': resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -7193,6 +7238,9 @@ packages: '@vvo/tzdb@6.198.0': resolution: {integrity: sha512-bNRWBhWYl0edVgyX6AYbhoCM2tk2lXJjGCyO2VDc2xn6Dw8dLd7WGj2DDXkVOkmOIQTNjEAcxrEpIzz5pWVwFg==} + '@webgpu/types@0.1.72': + resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -8412,6 +8460,9 @@ packages: resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -8948,6 +8999,9 @@ packages: long@5.2.5: resolution: {integrity: sha512-e0r9YBBgNCq1D1o5Dp8FMH0N5hsFtXDBiVa0qoJPHpakvZkmDKPRoGffZJII/XsHvj9An9blm+cRJ01yQqU+Dw==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -9302,6 +9356,12 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onnxruntime-common@1.27.0: + resolution: {integrity: sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==} + + onnxruntime-web@1.27.0: + resolution: {integrity: sha512-ogDLsqIozHZwifPuN37OproAo0byX6t43/bP8GzeZWBWD6MOGExswFAx3up4NS/vvWBOg2u2PXomDt3rMmdQSg==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -9467,6 +9527,9 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -9744,6 +9807,10 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -14457,6 +14524,26 @@ snapshots: '@preact/signals-core@1.14.4': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -16393,6 +16480,8 @@ snapshots: '@vvo/tzdb@6.198.0': {} + '@webgpu/types@0.1.72': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -17770,6 +17859,8 @@ snapshots: graphql@16.13.0: {} + guid-typescript@1.0.9: {} + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -18273,6 +18364,8 @@ snapshots: long@5.2.5: {} + long@5.3.2: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -18607,6 +18700,17 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onnxruntime-common@1.27.0: {} + + onnxruntime-web@1.27.0: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.2.5 + onnxruntime-common: 1.27.0 + platform: 1.3.6 + protobufjs: 7.6.6 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -18808,6 +18912,8 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + platform@1.3.6: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.13 @@ -19140,6 +19246,20 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 24.10.9 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 51d5e93b6..ef7c9b9a6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -77,6 +77,7 @@ catalog: '@types/react': 19.2.14 '@types/react-dom': 19.2.3 '@types/validator': 13.15.10 + '@webgpu/types': 0.1.72 '@typescript/native-preview': 7.0.0-dev.20260224.1 '@vitest/coverage-v8': 4.1.11 '@vvo/tzdb': 6.198.0 @@ -121,6 +122,8 @@ catalog: msw: 2.12.10 nats: 2.29.3 nodemailer: 9.0.6 + onnxruntime-common: 1.27.0 + onnxruntime-web: 1.27.0 pg: 8.16.3 pino: 10.3.1 pino-pretty: 13.1.3