feat(voice): add the camera background effects pipeline (#2378)

This commit is contained in:
Hampus
2026-09-02 17:31:04 +02:00
committed by GitHub
parent cadab239a2
commit 376168c922
39 changed files with 6802 additions and 37 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
+3
View File
@@ -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",
+7
View File
@@ -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',
@@ -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);
@@ -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,
@@ -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<void>;
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<void> = Promise.resolve();
private destroyPromise: Promise<void> | 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<void> {
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<void> {
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<void> {
destroy(): Promise<void> {
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<void> {
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<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation);
this.operationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
private async teardown(): Promise<void> {
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<void> {
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;
}
@@ -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<BackgroundImage>;
mirrorCamera?: boolean;
}
@@ -23,24 +30,120 @@ export interface AppliedBackgroundProcessor {
destroy: () => Promise<void>;
}
async function clearBackgroundProcessor(track: LocalVideoTrack): Promise<void> {
export async function clearCameraVideoProcessor(track: LocalVideoTrack): Promise<void> {
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<void> {
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<AppliedBackgroundProcessor | null> {
await clearBackgroundProcessor(track);
): Promise<AppliedBackgroundProcessor> {
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<WebCameraEffectConfig | null> {
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<AppliedBackgroundProcessor | null> {
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;
}
}
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ThrowCollectedFailuresRequest {
readonly failures: ReadonlyArray<unknown>;
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<unknown>): Array<unknown> {
const flattened: Array<unknown> = [];
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<PromiseLike<unknown>>): Promise<Array<unknown>> {
const outcomes = await Promise.allSettled(operations);
const failures: Array<unknown> = [];
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<unknown>]},
): 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);
}
@@ -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<T> {
readonly timeoutMilliseconds: number;
readonly description: string;
readonly signal: AbortSignal | null;
readonly operation: (signal: AbortSignal) => Promise<T>;
}
export interface CancellableResponseBody {
readonly body?: ReadableStream<Uint8Array> | 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<Uint8Array>;
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<BoundedResponseChunks> {
if (request.response.body == null) {
return {chunks: [], totalBytes: 0};
}
const reader = request.response.body.getReader();
const chunks: Array<Uint8Array> = [];
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<unknown> = [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<Uint8Array> {
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<ArrayBuffer> {
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<Blob> {
const {chunks} = await readBoundedResponseChunks(request);
const contentType = request.response.headers.get('content-type') ?? '';
return new Blob(chunks as Array<BlobPart>, {type: contentType});
}
export async function cancelResponseBodyAndThrow({
description,
error,
response,
}: CancelResponseBodyAndThrowRequest): Promise<never> {
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<T>(options: ResponseDeadlineOptions<T>): Promise<T> {
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);
}
}
}
@@ -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);
}
@@ -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<unknown> {
const collected: Array<unknown> = [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),
};
}
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
export interface TrackProcessorReadable<T> {
readonly readable: ReadableStream<T>;
}
type MediaStreamTrackProcessorInstance = {
readonly readable: ReadableStream<VideoFrame>;
};
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<T extends VideoFrame>(track: MediaStreamTrack): TrackProcessorReadable<T> {
const Processor = getMediaStreamTrackProcessorConstructor();
if (Processor != null) {
return new Processor({track}) as unknown as TrackProcessorReadable<T>;
}
return {readable: createPolyfilledVideoReadable(track) as ReadableStream<T>};
}
function createPolyfilledVideoReadable(track: MediaStreamTrack): ReadableStream<VideoFrame> {
return new PolyfilledVideoTrackProcessor(track).createReadable();
}
class PolyfilledVideoTrackProcessor {
private readonly video: HTMLVideoElement;
private stopped = false;
private callbackHandle: number | null = null;
private streamController: ReadableStreamDefaultController<VideoFrame> | 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<VideoFrame> {
return new ReadableStream<VideoFrame>({
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<VideoFrame>): 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<VideoFrame>,
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();
}
}
@@ -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<never>({
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;
}
@@ -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<void> {
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<WebCameraEffectCanvasRenderer> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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';
}
}
@@ -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<void>;
}
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<void> {
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<WebCameraEffectCustomFrame>;
private readonly frameEndMicroseconds: ReadonlyArray<number>;
private readonly totalDurationMicroseconds: number;
private readonly lease = new ReusableWebCameraEffectCustomFrameLease(() => {});
private epochMilliseconds: number | null = null;
private disposed = false;
constructor(frames: ReadonlyArray<VideoFrame>, frameEndMicroseconds: ReadonlyArray<number>) {
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<void> {
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<VideoFrame>;
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<void>;
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<void>;
private disposePromise: Promise<void> | null = null;
constructor(readable: ReadableStream<VideoFrame>) {
this.reader = readable.getReader();
this.firstFramePromise = new Promise<void>((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<void> {
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<void> {
if (this.disposePromise == null) {
this.disposePromise = this.disposeOwned();
}
return this.disposePromise;
}
private async pump(): Promise<void> {
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<void> {
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<unknown>): void {
const frame = this[key];
this[key] = null;
if (frame == null) return;
try {
frame.close();
} catch (error) {
failures.push(error);
}
}
private waitForLeaseRelease(): Promise<void> {
return new Promise<void>((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<WebCameraEffectCustomFrameImage>): Array<unknown> {
const failures: Array<unknown> = [];
for (const image of images) {
try {
image.close();
} catch (error) {
failures.push(error);
}
}
return failures;
}
function throwCleanupFailures(failures: ReadonlyArray<unknown>, 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<unknown>, message: string): never {
if (failures.length === 0) throw error;
throw new AggregateError([error, ...failures], message);
}
async function settledFailures(promises: ReadonlyArray<Promise<unknown>>): Promise<Array<unknown>> {
const outcomes = await Promise.allSettled(promises);
const failures: Array<unknown> = [];
for (const outcome of outcomes) {
if (outcome.status === 'rejected') failures.push(outcome.reason);
}
return failures;
}
function waitForAbortablePromise<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((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<WebCameraEffectCustomFrameSource> {
if (!animated && frameCount !== 1) {
throw new Error('Static custom camera background unexpectedly contains multiple frames');
}
const frames: Array<VideoFrame> = [];
const frameEndMicroseconds: Array<number> = [];
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<boolean>;
};
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<WebCameraEffectCustomFrameSource> {
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<unknown> = [];
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<WebCameraEffectCustomFrameSource> {
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<WebCameraEffectCustomFrameSource> {
if (mediaType === 'image/gif' || mediaType === 'image/webp') {
return decodeImageDecoderSource(blob, mediaType, signal);
}
return decodeStaticSource(blob, signal);
}
export async function createWebCameraEffectVideoFrameSource(
readable: ReadableStream<VideoFrame>,
): Promise<WebCameraEffectCustomFrameSource> {
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;
}
}
@@ -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<Blob> {
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<number>): 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<string> {
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<WebCameraEffectCustomFrameSource> {
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);
},
});
}
@@ -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);
}
@@ -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<void>;
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<string, unknown>;
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<MediaStreamTrack>, message: string): never {
const failures: Array<unknown> = [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<WebCameraEffectVideoFrameProducer | null> {
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<InitializationResult>;
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<InitializationResult>((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<WebCameraEffectPipeline> {
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<VideoFrame> | null = null;
try {
pipeline.currentVideoProducer = await createVideoFrameProducer(config);
readable = createTrackProcessor<VideoFrame>(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<Transferable> = [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<unknown> = [];
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<void> {
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<void>((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<Transferable> = [];
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<unknown> = [];
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<unknown> = [];
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<unknown>): 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<unknown> = [];
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<unknown> = [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<unknown> = [];
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'});
}
}
@@ -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<VideoFrame> | 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<VideoFrame>;
readonly customBackgroundFrames: ReadableStream<VideoFrame> | 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<VideoFrame> | 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;
@@ -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<void>;
render(frame: VideoFrame, now: number): Promise<void>;
dispose(): Promise<void>;
}
@@ -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<void>;
}
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<void>, 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<void> | 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<void> {
await physicalOperation.settlement;
this.requireNoDeferredFailure();
}
async settlePhysicalOperation(): Promise<void> {
const physicalOperation = this.physicalOperation;
if (physicalOperation != null) {
await physicalOperation.settlement;
}
this.requireNoDeferredFailure();
}
async settleForDisposal(): Promise<ReadonlyArray<unknown>> {
const failures: Array<unknown> = [];
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;
}
}
@@ -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<number> = [0x66, 0x74, 0x79, 0x70];
const EBML_MAGIC: ReadonlyArray<number> = [0x1a, 0x45, 0xdf, 0xa3];
const EBML_DOC_TYPE_ELEMENT_ID: ReadonlyArray<number> = [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<VideoFrame>;
readonly width: number;
readonly height: number;
stop(): void;
}
class WebCameraEffectVideoFrameProducerOwner implements WebCameraEffectVideoFrameProducer {
readonly readable: ReadableStream<VideoFrame>;
private readonly video: HTMLVideoElement;
private readonly objectURL: string;
private controller: ReadableStreamDefaultController<VideoFrame> | 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<VideoFrame>(
{
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<void> {
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<VideoFrame>): void | Promise<void> {
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<void>((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<unknown> {
if (this.stopped) return [];
this.stopped = true;
const failures: Array<unknown> = [];
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<unknown>): void {
if (this.callbackHandle == null) return;
try {
this.video.cancelVideoFrameCallback(this.callbackHandle);
} catch (error) {
failures.push(error);
}
this.callbackHandle = null;
}
private closePendingFrame(failures: Array<unknown>): 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<unknown>): 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<number>): 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<Blob> {
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<void> {
if (signal.aborted) return Promise.reject(signal.reason);
if (video.readyState >= 1 && video.videoWidth > 0 && video.videoHeight > 0) return Promise.resolve();
return new Promise<void>((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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((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<unknown>, 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<WebCameraEffectVideoFrameProducer> {
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;
}
},
});
}
@@ -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();
}
}
@@ -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;
@@ -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'},
}),
};
}
@@ -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<unknown>,
): Promise<GPUError | null> {
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<Record<string, OrtNamespace.Tensor>>,
): ReadonlyArray<unknown> {
const failures: Array<unknown> = [];
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<Array<unknown>> {
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<void> | 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<WebCameraEffectWebGPURenderer> {
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<unknown> = [];
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<void> {
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<FrameConfigurationResources> {
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<CustomTexture | null> {
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<void> {
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<void> {
if (this.disposePromise == null) {
this.disposePromise = this.disposeOwned();
}
return this.disposePromise;
}
private async disposeOwned(): Promise<void> {
invariant(!this.disposed, 'WebGPU camera effect disposal must have one owner');
this.disposed = true;
this.segmentationOwner.advanceLifecycle();
let segmentationFailures: ReadonlyArray<unknown>;
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<void> {
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<CustomTexture> {
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<void>;
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<void> {
const outputs = await this.session.run({[SEG_INPUT_NAME]: this.inputTensor});
const failures: Array<unknown> = [];
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'});
}
}
@@ -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, 3>(
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<storage, read_write> tensor: array<f32>;
@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<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
@group(0) @binding(2) var<uniform> 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<f32>;
@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<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
@group(0) @binding(2) var<uniform> 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<f32>;
@group(0) @binding(1) var background: texture_2d<f32>;
@group(0) @binding(2) var mask: texture_2d<f32>;
@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<storage, read> inferenceMask: array<f32>;
@group(0) @binding(1) var<storage, read_write> smoothedMask: array<f32>;
@group(0) @binding(2) var outputMask: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<uniform> 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));
}
`;
@@ -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<VideoFrame> | null,
): Promise<WebCameraEffectCustomFrameSource | null> {
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<RendererSelection> {
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<VideoFrame>;
private operationTail: Promise<void> = Promise.resolve();
private updatePreparationPromise: Promise<void> = Promise.resolve();
private disposePromise: Promise<void> | 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<void>;
constructor(
private readonly renderer: WebCameraEffectRenderer,
readable: ReadableStream<VideoFrame>,
private config: WebCameraPipelineConfig,
private customFrameSource: WebCameraEffectCustomFrameSource | null,
) {
this.reader = readable.getReader();
let capturedResolveOwnerStopIntent: (() => void) | null = null;
this.ownerStopIntent = new Promise<void>((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<void> {
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<void> {
if (!ownsCandidate || candidate == null) {
return;
}
await candidate.dispose();
}
private async disposeRejectedCandidate(
primaryError: unknown,
candidate: WebCameraEffectCustomFrameSource | null,
ownsCandidate: boolean,
): Promise<unknown> {
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<void> {
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<void> {
if (this.isShuttingDown()) {
return;
}
this.lifecycle = WebCameraEffectLifecycle.DRAINING;
if (await this.awaitOwnerStopIntent()) {
return;
}
throw new CameraEffectInputStreamEndedError();
}
private awaitOwnerStopIntent(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => {
resolve(false);
}, WEB_CAMERA_EFFECT_STOP_GRACE_MS);
void this.ownerStopIntent.then(() => {
clearTimeout(timeout);
resolve(true);
});
});
}
private enqueue(operation: () => Promise<void>, enforceDeadline = true): Promise<void> {
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<void>, enforceDeadline: boolean): Promise<void> {
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<void> {
if (this.disposePromise == null) {
const updateSettlement = this.updatePreparationPromise.then(
() => [] as Array<unknown>,
(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<void>): Promise<void> {
return new Promise<void>((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<PromiseSettledResult<void>>): 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>): 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<unknown>) => {
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<void> {
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();
@@ -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<WebSelfieOrtModule> | null = null;
private modelBytes: Promise<Uint8Array> | null = null;
loadOrt(): Promise<WebSelfieOrtModule> {
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<WebSelfieOrtModule>): void {
if (this.ortModule === cached) {
this.ortModule = null;
}
}
loadModel(): Promise<Uint8Array> {
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<Uint8Array>): void {
if (this.modelBytes === cached) {
this.modelBytes = null;
}
}
}
const webSelfieRuntimeAssetOwner = new WebSelfieRuntimeAssetOwner();
async function loadWebSelfieOrt(): Promise<WebSelfieOrtModule> {
return webSelfieRuntimeAssetOwner.loadOrt();
}
async function loadWebSelfieModel(): Promise<Uint8Array> {
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<WebSelfieRuntime> {
const [ortOutcome, modelBytesOutcome] = await Promise.allSettled([loadWebSelfieOrt(), loadWebSelfieModel()]);
const failures: Array<unknown> = [];
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<Record<string, OrtNamespace.Tensor>>,
): ReadonlyArray<unknown> {
const failures: Array<unknown> = [];
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<WebSelfieSegmenter> {
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<void> {
const outputs = await this.session.run({[SEG_INPUT_NAME]: this.inputTensor});
const failures: Array<unknown> = [];
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<void> {
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<unknown> = [];
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<void> {
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',
});
}
}
@@ -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.
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
declare const url: string;
export default url;
+5
View File
@@ -58,3 +58,8 @@ declare module '@pkgs/libfluxcore/libfluxcore_bg.wasm' {
const url: string;
export default url;
}
declare module '*.onnx' {
const url: string;
export default url;
}
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
declare const url: string;
export default url;
+1
View File
@@ -0,0 +1 @@
declare let __webpack_base_uri__: string;
+2
View File
@@ -0,0 +1,2 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/// <reference types="@webgpu/types" />
+1
View File
@@ -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/*"],
+120
View File
@@ -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
+3
View File
@@ -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