fix(voice): request a real camera frame rate at capture (#2418)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 04:46:32 +02:00
1 parent c97bc53342
commit 9d63eb15a9
4 files changed
+30 -48

No files matched your search

@@ -24,6 +24,7 @@ import {useMaybeVoiceRoom} from '@app/features/voice/components/VoiceRoomContext
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
import {VOICE_CAMERA_USER_LIMIT_REACHED_DESCRIPTOR} from '@app/features/voice/engine/media_engine_facade/shared';
import VoiceDevicePermissionState from '@app/features/voice/engine/VoiceDevicePermissionState';
import {getCameraCaptureDimensions} from '@app/features/voice/engine/v2/VoiceEngineV2AppCameraResolutionPresets';
import {useCameraUserCapBlocked} from '@app/features/voice/hooks/useCameraUserCapBlocked';
import VoiceSettings, {
BLUR_BACKGROUND_ID,
@@ -90,12 +91,6 @@ interface CameraPreviewModalProps {
isCameraEnabled?: boolean;
}
interface VideoResolutionPreset {
width: number;
height: number;
frameRate: number;
}
const TARGET_ASPECT_RATIO = 16 / 9;
const ASPECT_RATIO_TOLERANCE = 0.1;
const RESOLUTION_WAIT_TIMEOUT = 2000;
@@ -108,11 +103,6 @@ const VIDEO_READY_STATE_HAS_CURRENT_DATA = 2;
const RESOLUTION_FIX_TRIGGER_DELAY = 800;
const RESOLUTION_FIX_SETTLE_DELAY = 1200;
const RESOLUTION_FIX_SWITCH_BACK_DELAY = 500;
const CAMERA_RESOLUTION_PRESETS: Record<'low' | 'medium' | 'high', VideoResolutionPreset> = {
low: {width: 640, height: 360, frameRate: 24},
medium: {width: 1280, height: 720, frameRate: 30},
high: {width: 1920, height: 1080, frameRate: 30},
};
interface CameraPreviewConfig {
videoDeviceId: string;
@@ -280,15 +270,15 @@ async function setupPreviewTrackAndProcessor(args: CameraPreviewTrackSetupArgs):
await args.processorRef.current.destroy();
args.processorRef.current = null;
}
const resolutionPreset = CAMERA_RESOLUTION_PRESETS[args.cameraResolution];
const captureDimensions = getCameraCaptureDimensions(args.cameraResolution);
const track = await createLocalVideoTrack({
deviceId:
args.effectiveVideoDeviceId && args.effectiveVideoDeviceId !== 'default'
? args.effectiveVideoDeviceId
: undefined,
resolution: {
width: resolutionPreset.width,
height: resolutionPreset.height,
width: captureDimensions.width,
height: captureDimensions.height,
frameRate: args.videoFrameRate,
aspectRatio: TARGET_ASPECT_RATIO,
},
@@ -3,16 +3,12 @@
import type {CameraResolution} from '@app/features/voice/state/VoiceSettings';
import {VideoPresets} from 'livekit-client';
import {describe, expect, it} from 'vitest';
import {
getCameraCaptureDimensions,
getCameraVideoPreset,
pickCameraSubscriptionQuality,
} from './VoiceEngineV2AppCameraResolutionPresets';
import {getCameraCaptureDimensions, pickCameraSubscriptionQuality} from './VoiceEngineV2AppCameraResolutionPresets';
const EXPECTED_PRESET_TABLE = [
{resolution: 'high', width: 1920, height: 1080, frameRate: 30, videoPreset: VideoPresets.h1080},
{resolution: 'medium', width: 1280, height: 720, frameRate: 30, videoPreset: VideoPresets.h720},
{resolution: 'low', width: 640, height: 360, frameRate: 24, videoPreset: VideoPresets.h360},
{resolution: 'low', width: 640, height: 360, frameRate: 20, videoPreset: VideoPresets.h360},
] as const;
describe('getCameraCaptureDimensions', () => {
@@ -28,29 +24,17 @@ describe('getCameraCaptureDimensions', () => {
it('falls back to the low entry for an unknown resolution key', () => {
const dimensions = getCameraCaptureDimensions('ultra' as CameraResolution);
expect(dimensions).toEqual({width: 640, height: 360, frameRate: 24});
});
});
describe('getCameraVideoPreset', () => {
for (const row of EXPECTED_PRESET_TABLE) {
it(`maps ${row.resolution} to the ${row.videoPreset.height}p LiveKit preset`, () => {
expect(getCameraVideoPreset(row.resolution)).toBe(row.videoPreset);
});
}
it('falls back to the 360p preset for an unknown resolution key', () => {
expect(getCameraVideoPreset('4k' as CameraResolution)).toBe(VideoPresets.h360);
expect(dimensions).toEqual({width: 640, height: 360, frameRate: 20});
});
});
describe('camera preset table consistency', () => {
for (const row of EXPECTED_PRESET_TABLE) {
it(`keeps capture dimensions and video preset in agreement for ${row.resolution}`, () => {
it(`keeps capture dimensions and the LiveKit encode preset in agreement for ${row.resolution}`, () => {
const capture = getCameraCaptureDimensions(row.resolution);
const preset = getCameraVideoPreset(row.resolution);
expect(preset.width).toBe(capture.width);
expect(preset.height).toBe(capture.height);
expect(row.videoPreset.width).toBe(capture.width);
expect(row.videoPreset.height).toBe(capture.height);
expect(row.videoPreset.encoding.maxFramerate).toBe(capture.frameRate);
});
}
@@ -62,6 +46,16 @@ describe('camera preset table consistency', () => {
});
});
describe('camera capture constraint shape', () => {
for (const row of EXPECTED_PRESET_TABLE) {
it(`hands getUserMedia a plain video resolution for ${row.resolution}`, () => {
const capture = getCameraCaptureDimensions(row.resolution);
expect(Object.keys(capture).sort()).toEqual(['frameRate', 'height', 'width']);
expect(capture.frameRate).toBeGreaterThan(0);
});
}
});
describe('pickCameraSubscriptionQuality', () => {
it('requests the lowest layer for a hidden or zero-sized tile', () => {
expect(pickCameraSubscriptionQuality(0, 0)).toBe('low');
@@ -14,12 +14,17 @@ export interface CameraCaptureDimensions {
const CAMERA_RESOLUTION_PRESET_TABLE = {
high: {capture: {width: 1920, height: 1080, frameRate: 30}, videoPreset: VideoPresets.h1080},
medium: {capture: {width: 1280, height: 720, frameRate: 30}, videoPreset: VideoPresets.h720},
low: {capture: {width: 640, height: 360, frameRate: 24}, videoPreset: VideoPresets.h360},
low: {capture: {width: 640, height: 360, frameRate: 20}, videoPreset: VideoPresets.h360},
} as const;
for (const [resolution, entry] of Object.entries(CAMERA_RESOLUTION_PRESET_TABLE)) {
assert.equal(entry.videoPreset.width, entry.capture.width, `camera preset width must agree for ${resolution}`);
assert.equal(entry.videoPreset.height, entry.capture.height, `camera preset height must agree for ${resolution}`);
assert.equal(
entry.videoPreset.encoding.maxFramerate,
entry.capture.frameRate,
`camera preset frame rate must agree for ${resolution}`,
);
}
function resolvePresetEntry(resolution: CameraResolution) {
@@ -35,13 +40,6 @@ export function getCameraCaptureDimensions(resolution: CameraResolution): Camera
return dimensions;
}
export function getCameraVideoPreset(resolution: CameraResolution) {
const preset = resolvePresetEntry(resolution).videoPreset;
assert.ok(preset.width > 0, 'camera video preset width must be positive');
assert.ok(preset.height > 0, 'camera video preset height must be positive');
return preset;
}
const CAMERA_SUBSCRIPTION_QUALITY_LAYERS = [
{quality: 'low', layer: VideoPresets.h180},
{quality: 'medium', layer: VideoPresets.h360},
@@ -66,7 +66,7 @@ import {
isMutedOrDeafened,
isPermissionDeniedError,
} from '@app/features/voice/engine/v2/VoiceEngineV2AppAdapterAssertions';
import {getCameraVideoPreset} from '@app/features/voice/engine/v2/VoiceEngineV2AppCameraResolutionPresets';
import {getCameraCaptureDimensions} from '@app/features/voice/engine/v2/VoiceEngineV2AppCameraResolutionPresets';
import {
runCameraTransition,
type VoiceEngineV2AppCameraTransitionOutcome,
@@ -1141,8 +1141,8 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
await clearCameraVideoProcessor(cameraTrack);
}
}
const videoResolution = getCameraVideoPreset(VoiceSettings.getCameraResolution());
await participant.setCameraEnabled(enabled, {resolution: videoResolution, ...restOptions});
const captureDimensions = getCameraCaptureDimensions(VoiceSettings.getCameraResolution());
await participant.setCameraEnabled(enabled, {resolution: captureDimensions, ...restOptions});
await this.enforceCameraPublicationCap(participant, enabled ? 'after camera enable' : 'after camera disable');
if (enabled) {
await this.applyBackgroundToCamera(participant);