Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/hooks/useRecording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import { ConferenceClient } from 'red5pro-conference-sdk';
import { ConferenceEvents } from 'red5pro-conference-sdk';
import { MediabunnyRecorder } from '../utils/MediabunnyRecorder';
import { createCompositeStream } from '../utils/compositeStream';
import { withTimeout, TimeoutError } from '../utils/withTimeout';
import JSZip from 'jszip';
import { S3Client } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';

// Some browsers (notably Safari) can hang indefinitely while flushing WebCodecs
// encoders on stop(); cap the wait so the UI never gets stuck showing "recording".
const RECORDER_STOP_TIMEOUT_MS = 15000;

// Type definitions
type MessageVariant = 'info' | 'success' | 'error' | 'warning';

Expand Down Expand Up @@ -741,12 +746,20 @@ export const useRecording = (
}

try {
const blob = await mediabunnyRecorderRef.current.stop();
const blob = await withTimeout(
mediabunnyRecorderRef.current.stop(),
RECORDER_STOP_TIMEOUT_MS,
'Timed out stopping local recording',
);

// Stop the local-only recorder alongside the composite one
if (localOnlyRecorderRef.current) {
try {
const localOnlyBlob = await localOnlyRecorderRef.current.stop();
const localOnlyBlob = await withTimeout(
localOnlyRecorderRef.current.stop(),
RECORDER_STOP_TIMEOUT_MS,
'Timed out stopping local-only recording',
);
if (localOnlyBlob) {
localOnlyRecordedSegmentsRef.current.push(localOnlyBlob);
}
Expand Down Expand Up @@ -808,7 +821,12 @@ export const useRecording = (
currentRecordingStreamRef.current = null;
localStreamRef.current = null;
if (displayMessageRef.current) {
displayMessageRef.current('Failed to stop local recording', 'error');
displayMessageRef.current(
error instanceof TimeoutError
? 'Stopping local recording took too long and was aborted'
: 'Failed to stop local recording',
'error',
);
}
return null;
}
Expand Down
29 changes: 26 additions & 3 deletions src/hooks/useVirtualBackground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { useState, useCallback, useEffect, useRef, MutableRefObject } from 'reac
import { getVirtualBackgroundConfigs } from '../utils/conferenceConfig';
import { VirtualBackgroundTypes } from 'red5pro-conference-sdk';
import log from 'loglevel';
import { withTimeout } from '../utils/withTimeout';

// Some browsers (notably Safari, which falls back to an OffscreenCanvas/WASM
// segmentation pipeline instead of insertable streams) can hang indefinitely
// when initializing or applying a virtual background. Cap the wait so the UI
// never gets stuck reporting the effect as enabled when it isn't working.
const VIRTUAL_BACKGROUND_TIMEOUT_MS = 15000;

// Type definitions
type VirtualBackgroundType = 'none' | 'blur' | 'slight-blur' | 'color' | 'image';
Expand Down Expand Up @@ -152,7 +159,11 @@ export const useVirtualBackground = (
const status = conferenceClientRef.current.getVirtualBackgroundStatus();

if (!status.isInitialized) {
await conferenceClientRef.current.initializeVirtualBackground();
await withTimeout(
conferenceClientRef.current.initializeVirtualBackground(),
VIRTUAL_BACKGROUND_TIMEOUT_MS,
'Timed out initializing virtual background',
);
log.log('Virtual background initialized');
setIsVirtualBackgroundInitialized(true);
}
Expand Down Expand Up @@ -204,7 +215,11 @@ export const useVirtualBackground = (

// Handle disable case
if (type === VirtualBackgroundTypes.NONE) {
await conferenceClientRef.current.disableVirtualBackground();
await withTimeout(
conferenceClientRef.current.disableVirtualBackground(),
VIRTUAL_BACKGROUND_TIMEOUT_MS,
'Timed out disabling virtual background',
);
setSelectedBackgroundMode('');
return true;
}
Expand Down Expand Up @@ -233,14 +248,22 @@ export const useVirtualBackground = (
}

// Apply the background
await conferenceClientRef.current[action](config.type, config.options);
await withTimeout(
conferenceClientRef.current[action](config.type, config.options),
VIRTUAL_BACKGROUND_TIMEOUT_MS,
`Timed out applying virtual background (${action})`,
);
setSelectedBackgroundMode(type);

log.log(`Virtual background ${action} successful: ${type}`);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
log.error('Failed to handle background replacement:', error);
// Ensure the UI never reports the effect as enabled when the apply call
// hung/failed and the SDK never actually produced the processed frame.
setIsVirtualBackgroundEnabled(false);
setSelectedBackgroundMode('');
if (showWarningRef.current) {
showWarningRef.current('Failed to apply virtual background: ' + errorMessage);
}
Expand Down
7 changes: 4 additions & 3 deletions src/utils/MediabunnyRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,6 @@ export class MediabunnyRecorder {
this.recordedBlob = new Blob([this.target.buffer], { type: 'video/mp4' });
}

this._isRecording = false;
this._isPaused = false;

if (this.onstop && this.recordedBlob) {
this.onstop(this.recordedBlob);
}
Expand All @@ -163,6 +160,10 @@ export class MediabunnyRecorder {
}
throw error;
} finally {
// Reset regardless of outcome so callers never see a stuck "recording" state
// if finalize() throws or is abandoned via a timeout.
this._isRecording = false;
this._isPaused = false;
this.cleanup();
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/utils/withTimeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Races a promise against a timeout so a hung Safari media API call
* (WebCodecs encoder flush, WASM/OffscreenCanvas segmentation, etc.)
* can't leave callers awaiting forever.
*/
export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = 'TimeoutError';
}
}

export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new TimeoutError(message)), ms);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
});
}
Loading