mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(voice): judge H.264 hardware support by negotiated format (#2680)
This commit is contained in:
1 parent
81fd8c9aad
commit
67e01be34a
6 files changed
+429
-28
No files matched your search
@@ -1,11 +1,38 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {findStalledVideoEncoder} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
import {
|
||||||
import {describe, expect, test, vi} from 'vitest';
|
findStalledVideoEncoder,
|
||||||
|
scheduleScreenShareEncoderVerification,
|
||||||
|
shouldTriggerSoftwareEncoderWarning,
|
||||||
|
} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||||
|
import SoftwareEncoderWarning from '@app/features/voice/state/SoftwareEncoderWarning';
|
||||||
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
|
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||||
|
|
||||||
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({hasHigherVideoQuality: () => false}));
|
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({hasHigherVideoQuality: () => false}));
|
||||||
|
|
||||||
|
let h264Acceleration = 'hardware';
|
||||||
|
const markSoftwareEncodeObserved = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/utils/CodecCapabilityDetector', () => ({
|
||||||
|
adjustScreenShareEncodingForCodec: (encoding: unknown) => encoding,
|
||||||
|
getCodecCapabilityReport: () => ({
|
||||||
|
vp8: {hardwareAccelerated: 'software'},
|
||||||
|
vp9: {hardwareAccelerated: 'software'},
|
||||||
|
h264: {hardwareAccelerated: h264Acceleration},
|
||||||
|
h265: {hardwareAccelerated: 'software'},
|
||||||
|
av1: {hardwareAccelerated: 'software'},
|
||||||
|
}),
|
||||||
|
markScreenShareCodecSoftwareEncodeObserved: (codec: string) => markSoftwareEncodeObserved(codec),
|
||||||
|
resolveVideoPublishCodecPolicy: (requested: string) => ({
|
||||||
|
allowed: [requested],
|
||||||
|
requested,
|
||||||
|
primary: requested,
|
||||||
|
backupCodec: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
function makeStats(entries: ReadonlyArray<Record<string, unknown>>): RTCStatsReport {
|
function makeStats(entries: ReadonlyArray<Record<string, unknown>>): RTCStatsReport {
|
||||||
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
|
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
|
||||||
}
|
}
|
||||||
@@ -59,3 +86,72 @@ describe('findStalledVideoEncoder', () => {
|
|||||||
expect(findStalledVideoEncoder(stats, 'h264')?.sourceFrames).toBe(240);
|
expect(findStalledVideoEncoder(stats, 'h264')?.sourceFrames).toBe(240);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the screen share encoder verification', () => {
|
||||||
|
function openH264Stats(): RTCStatsReport {
|
||||||
|
return makeStats([
|
||||||
|
CODEC_REPORT,
|
||||||
|
SOURCE_REPORT,
|
||||||
|
outboundReport({
|
||||||
|
active: true,
|
||||||
|
framesEncoded: 120,
|
||||||
|
framesSent: 120,
|
||||||
|
encoderImplementation: 'OpenH264',
|
||||||
|
powerEfficientEncoder: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOpenH264Share(): Promise<void> {
|
||||||
|
scheduleScreenShareEncoderVerification(async () => openH264Stats(), 'h264');
|
||||||
|
await vi.advanceTimersByTimeAsync(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
h264Acceleration = 'hardware';
|
||||||
|
markSoftwareEncodeObserved.mockClear();
|
||||||
|
SoftwareEncoderWarning.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
SoftwareEncoderWarning.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records a software session verdict when the encoder turns out to be OpenH264', async () => {
|
||||||
|
await verifyOpenH264Share();
|
||||||
|
expect(markSoftwareEncodeObserved).toHaveBeenCalledWith('h264');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns about the software encoder while the capability layer still claims hardware', async () => {
|
||||||
|
await verifyOpenH264Share();
|
||||||
|
expect(SoftwareEncoderWarning.showWarning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not warn once the session verdict says the codec encodes in software', async () => {
|
||||||
|
h264Acceleration = 'software';
|
||||||
|
await verifyOpenH264Share();
|
||||||
|
expect(SoftwareEncoderWarning.showWarning).toBe(false);
|
||||||
|
expect(markSoftwareEncodeObserved).toHaveBeenCalledWith('h264');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('records no verdict when the user asked for the software encoder', async () => {
|
||||||
|
vi.spyOn(VoiceSettings, 'getScreenShareEncoderMode').mockReturnValue('software');
|
||||||
|
await verifyOpenH264Share();
|
||||||
|
expect(markSoftwareEncodeObserved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the software encoder warning gate', () => {
|
||||||
|
test('stays quiet once the session verdict says the codec encodes in software', () => {
|
||||||
|
h264Acceleration = 'software';
|
||||||
|
expect(shouldTriggerSoftwareEncoderWarning('h264')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still speaks up while the capability layer claims hardware', () => {
|
||||||
|
h264Acceleration = 'hardware';
|
||||||
|
expect(shouldTriggerSoftwareEncoderWarning('h264')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
|||||||
import {
|
import {
|
||||||
adjustScreenShareEncodingForCodec,
|
adjustScreenShareEncodingForCodec,
|
||||||
getCodecCapabilityReport,
|
getCodecCapabilityReport,
|
||||||
|
markScreenShareCodecSoftwareEncodeObserved,
|
||||||
resolveVideoPublishCodecPolicy,
|
resolveVideoPublishCodecPolicy,
|
||||||
type VideoPublishCodecPolicy,
|
type VideoPublishCodecPolicy,
|
||||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||||
@@ -681,7 +682,11 @@ export function scheduleScreenShareEncoderVerification(
|
|||||||
powerEfficientEncoder: encoder.powerEfficientEncoder,
|
powerEfficientEncoder: encoder.powerEfficientEncoder,
|
||||||
expectedHardware,
|
expectedHardware,
|
||||||
});
|
});
|
||||||
if (shouldTriggerSoftwareEncoderWarning(codec)) {
|
const warnAboutSoftwareEncoder = shouldTriggerSoftwareEncoderWarning(codec);
|
||||||
|
if (VoiceSettings.getScreenShareEncoderMode() !== 'software') {
|
||||||
|
markScreenShareCodecSoftwareEncodeObserved(codec);
|
||||||
|
}
|
||||||
|
if (warnAboutSoftwareEncoder) {
|
||||||
SoftwareEncoderWarning.triggerWarning(codec, encoder.implementation);
|
SoftwareEncoderWarning.triggerWarning(codec, encoder.implementation);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {afterEach, beforeEach, describe, expect, it, type MockInstance, vi} from
|
|||||||
let av1OptIn = false;
|
let av1OptIn = false;
|
||||||
let hevcOptIn = false;
|
let hevcOptIn = false;
|
||||||
let desktop = true;
|
let desktop = true;
|
||||||
|
let platform = 'windows';
|
||||||
let gpuReport: HardwareEncodeReport | null = null;
|
let gpuReport: HardwareEncodeReport | null = null;
|
||||||
|
let nativeHardwareEncoder: {backend: string; codecs: ReadonlyArray<string>} | null = null;
|
||||||
let cameraPreference = 'auto';
|
let cameraPreference = 'auto';
|
||||||
let openH264Status: {enabled: boolean; downloaded: boolean} | null = null;
|
let openH264Status: {enabled: boolean; downloaded: boolean} | null = null;
|
||||||
let codecCapabilityInfo: MockInstance<(...args: Array<unknown>) => void>;
|
let codecCapabilityInfo: MockInstance<(...args: Array<unknown>) => void>;
|
||||||
@@ -25,7 +27,7 @@ vi.mock('@app/features/devtools/utils/DesktopTroubleshootingUtils', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
||||||
guessPlatform: () => 'windows',
|
guessPlatform: () => platform,
|
||||||
isChromiumBrowser: () => true,
|
isChromiumBrowser: () => true,
|
||||||
isDesktop: () => desktop,
|
isDesktop: () => desktop,
|
||||||
isFirefoxBrowser: () => false,
|
isFirefoxBrowser: () => false,
|
||||||
@@ -36,8 +38,8 @@ vi.mock('@app/features/voice/utils/GpuEncoderCapabilities', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@app/features/voice/utils/NativeHardwareEncoderCapabilities', () => ({
|
vi.mock('@app/features/voice/utils/NativeHardwareEncoderCapabilities', () => ({
|
||||||
getNativeHardwareEncoderCapabilitiesSync: () => null,
|
getNativeHardwareEncoderCapabilitiesSync: () => nativeHardwareEncoder,
|
||||||
hasNativeHardwareEncoder: () => false,
|
hasNativeHardwareEncoder: (codec: string) => nativeHardwareEncoder?.codecs.includes(codec) === true,
|
||||||
resetNativeHardwareEncoderCapabilities: () => undefined,
|
resetNativeHardwareEncoderCapabilities: () => undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ const {
|
|||||||
getRoomVideoPublishDefaults,
|
getRoomVideoPublishDefaults,
|
||||||
isVideoCodecAllowedForPublish,
|
isVideoCodecAllowedForPublish,
|
||||||
markScreenShareCodecEncodeRuntimeFailure,
|
markScreenShareCodecEncodeRuntimeFailure,
|
||||||
|
markScreenShareCodecSoftwareEncodeObserved,
|
||||||
resetCachedCodecCapabilities,
|
resetCachedCodecCapabilities,
|
||||||
resolveScreenShareEncoderVerificationAction,
|
resolveScreenShareEncoderVerificationAction,
|
||||||
resolveVideoPublishCodecPolicy,
|
resolveVideoPublishCodecPolicy,
|
||||||
@@ -288,3 +291,66 @@ describe('video publish codec policy', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('H.264 hardware verdict', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
av1OptIn = false;
|
||||||
|
hevcOptIn = false;
|
||||||
|
desktop = true;
|
||||||
|
platform = 'windows';
|
||||||
|
gpuReport = null;
|
||||||
|
nativeHardwareEncoder = null;
|
||||||
|
cameraPreference = 'auto';
|
||||||
|
openH264Status = null;
|
||||||
|
senderCodecs = ALL_SENDER_CODECS;
|
||||||
|
resetCachedCodecCapabilities();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
platform = 'windows';
|
||||||
|
nativeHardwareEncoder = null;
|
||||||
|
resetCachedCodecCapabilities();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats H.264 as software until the GPU probe has resolved', () => {
|
||||||
|
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pick hardware H.264 on a Windows AMD machine whose probe came back software', () => {
|
||||||
|
gpuReport = {...ALL_SOFTWARE, gpuFamily: 'amd-rdna3-plus'};
|
||||||
|
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hardware H.264 on Linux when the probe reported a power efficient encoder', () => {
|
||||||
|
platform = 'linux';
|
||||||
|
gpuReport = {...ALL_SOFTWARE, h264: 'hardware'};
|
||||||
|
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('hardware');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a macOS native VideoToolbox encoder stand in for hardware H.264', () => {
|
||||||
|
platform = 'macos';
|
||||||
|
nativeHardwareEncoder = {backend: 'videotoolbox', codecs: ['h264']};
|
||||||
|
const report = getCodecCapabilityReport();
|
||||||
|
expect(report.h264.supported).toBe(true);
|
||||||
|
expect(report.h264.hardwareAccelerated).toBe('software');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto').reason).not.toBe('hardware-h264');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still counts a native NVENC encoder as hardware H.264', () => {
|
||||||
|
platform = 'linux';
|
||||||
|
nativeHardwareEncoder = {backend: 'nvenc', codecs: ['h264']};
|
||||||
|
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('hardware');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops selecting hardware H.264 once the session verdict says it encoded in software', () => {
|
||||||
|
gpuReport = {...ALL_SOFTWARE, h264: 'hardware'};
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'h264', reason: 'hardware-h264'});
|
||||||
|
expect(markScreenShareCodecSoftwareEncodeObserved('h264')).toBe(true);
|
||||||
|
expect(getCodecCapabilityReport().h264.hardwareAccelerated).toBe('software');
|
||||||
|
expect(selectAutomaticScreenShareCodec('auto')).toEqual({codec: 'vp9', reason: 'software-vp9'});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,6 +92,7 @@ let cachedReportHardwareAccelerationDisabled: boolean | undefined;
|
|||||||
let cachedReportAv1OptIn: boolean | undefined;
|
let cachedReportAv1OptIn: boolean | undefined;
|
||||||
let cachedReportHevcOptIn: boolean | undefined;
|
let cachedReportHevcOptIn: boolean | undefined;
|
||||||
const runtimeEncodeFailureCodecs = new Set<VideoCodec>();
|
const runtimeEncodeFailureCodecs = new Set<VideoCodec>();
|
||||||
|
const observedSoftwareEncodeCodecs = new Set<VideoCodec>();
|
||||||
|
|
||||||
interface RawProbeResult {
|
interface RawProbeResult {
|
||||||
caps: CodecCapabilities;
|
caps: CodecCapabilities;
|
||||||
@@ -223,6 +224,11 @@ function getEffectiveScreenShareCapabilities(caps: CodecCapabilities): CodecCapa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPublishPathNativeHardwareEncoder(codec: VideoCodec): boolean {
|
||||||
|
if (!hasNativeHardwareEncoder(codec)) return false;
|
||||||
|
return getNativeHardwareEncoderCapabilitiesSync()?.backend !== 'videotoolbox';
|
||||||
|
}
|
||||||
|
|
||||||
function buildReport(): CodecCapabilityReport {
|
function buildReport(): CodecCapabilityReport {
|
||||||
const {caps, probedSuccessfully} = probeRawCapabilities();
|
const {caps, probedSuccessfully} = probeRawCapabilities();
|
||||||
const context = buildScreenShareCodecPolicyContext();
|
const context = buildScreenShareCodecPolicyContext();
|
||||||
@@ -231,18 +237,19 @@ function buildReport(): CodecCapabilityReport {
|
|||||||
const linuxNvidiaWebRtcEncodeLimited =
|
const linuxNvidiaWebRtcEncodeLimited =
|
||||||
context.platform === 'linux' && gpuReport?.gpuFamily?.startsWith('nvidia-') === true;
|
context.platform === 'linux' && gpuReport?.gpuFamily?.startsWith('nvidia-') === true;
|
||||||
const hardwareAccelerationDisabled = isDesktopHardwareAccelerationDisabled();
|
const hardwareAccelerationDisabled = isDesktopHardwareAccelerationDisabled();
|
||||||
const openH264Status = getOpenH264StatusSync();
|
|
||||||
const openH264Active = openH264Status?.enabled === true && openH264Status.downloaded === true;
|
|
||||||
function hwAccel(codec: keyof CodecCapabilities): HardwareEncodeAnswer {
|
function hwAccel(codec: keyof CodecCapabilities): HardwareEncodeAnswer {
|
||||||
if (hardwareAccelerationDisabled) {
|
if (hardwareAccelerationDisabled) {
|
||||||
return 'software';
|
return 'software';
|
||||||
}
|
}
|
||||||
if (hasNativeHardwareEncoder(codec)) {
|
if (observedSoftwareEncodeCodecs.has(codec)) {
|
||||||
|
return 'software';
|
||||||
|
}
|
||||||
|
if (hasPublishPathNativeHardwareEncoder(codec)) {
|
||||||
return 'hardware';
|
return 'hardware';
|
||||||
}
|
}
|
||||||
const gpu = gpuReport ? gpuReport[codec] : 'unknown';
|
const gpu = gpuReport ? gpuReport[codec] : 'unknown';
|
||||||
if (codec === 'h264' && openH264Active && gpu === 'unknown') {
|
if (codec === 'h264') {
|
||||||
return 'software';
|
return gpu === 'hardware' ? 'hardware' : 'software';
|
||||||
}
|
}
|
||||||
return gpu;
|
return gpu;
|
||||||
}
|
}
|
||||||
@@ -481,6 +488,14 @@ export function markScreenShareCodecEncodeRuntimeFailure(codec: VideoCodec, reas
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markScreenShareCodecSoftwareEncodeObserved(codec: VideoCodec): boolean {
|
||||||
|
if (observedSoftwareEncodeCodecs.has(codec)) return false;
|
||||||
|
observedSoftwareEncodeCodecs.add(codec);
|
||||||
|
cachedReport = null;
|
||||||
|
logger.warn('Treating this codec as software-encoded for the rest of the session', {codec});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export type VideoPublishCodecDenial = 'sender-cannot-encode' | 'policy' | 'runtime-failed';
|
export type VideoPublishCodecDenial = 'sender-cannot-encode' | 'policy' | 'runtime-failed';
|
||||||
|
|
||||||
export interface VideoPublishCodecPolicy {
|
export interface VideoPublishCodecPolicy {
|
||||||
@@ -654,6 +669,7 @@ export function resetCachedCodecCapabilities(): void {
|
|||||||
cachedReportAv1OptIn = undefined;
|
cachedReportAv1OptIn = undefined;
|
||||||
cachedReportHevcOptIn = undefined;
|
cachedReportHevcOptIn = undefined;
|
||||||
runtimeEncodeFailureCodecs.clear();
|
runtimeEncodeFailureCodecs.clear();
|
||||||
|
observedSoftwareEncodeCodecs.clear();
|
||||||
resetNativeHardwareEncoderCapabilities();
|
resetNativeHardwareEncoderCapabilities();
|
||||||
resetOpenH264Status();
|
resetOpenH264Status();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||||
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
let streamingMode = 'custom';
|
||||||
|
let screenshareResolution = 'high';
|
||||||
|
let videoFrameRate = 60;
|
||||||
|
let higherVideoQuality = true;
|
||||||
|
let shareSettingsUnavailable = false;
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||||
|
default: {
|
||||||
|
getStreamingMode: () => {
|
||||||
|
if (shareSettingsUnavailable) throw new ReferenceError('Cannot access VoiceSettings before initialization');
|
||||||
|
return streamingMode;
|
||||||
|
},
|
||||||
|
getScreenshareResolution: () => screenshareResolution,
|
||||||
|
getVideoFrameRate: () => videoFrameRate,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({
|
||||||
|
hasHigherVideoQuality: () => higherVideoQuality,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
||||||
|
getElectronAPI: () => null,
|
||||||
|
getNativePlatformSync: () => 'windows',
|
||||||
|
isDesktop: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface EncodingInfoConfig {
|
||||||
|
type: string;
|
||||||
|
video: {contentType: string; width: number; height: number; bitrate: number; framerate: number};
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodingInfoConfigs: Array<EncodingInfoConfig> = [];
|
||||||
|
let encodingInfoAnswer: () => {supported?: boolean; powerEfficient?: boolean} = () => ({
|
||||||
|
supported: true,
|
||||||
|
powerEfficient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'navigator', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
mediaCapabilities: {
|
||||||
|
encodingInfo: async (config: EncodingInfoConfig) => {
|
||||||
|
encodingInfoConfigs.push(config);
|
||||||
|
return encodingInfoAnswer();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
AMD_RDNA3_PLUS,
|
||||||
|
NEGOTIABLE_H264_PROBE_CONTENT_TYPE,
|
||||||
|
NVIDIA_PRE_ADA,
|
||||||
|
PCI_VENDOR_AMD,
|
||||||
|
PCI_VENDOR_APPLE,
|
||||||
|
PCI_VENDOR_NVIDIA,
|
||||||
|
probeWebRtcEncodeEfficiency,
|
||||||
|
reconcileHardwareEncodeReport,
|
||||||
|
} = await import('./GpuEncoderCapabilities');
|
||||||
|
|
||||||
|
const AMD_RDNA3_REPORT: HardwareEncodeReport = {...AMD_RDNA3_PLUS.caps, gpuFamily: AMD_RDNA3_PLUS.family};
|
||||||
|
const NVIDIA_REPORT: HardwareEncodeReport = {...NVIDIA_PRE_ADA.caps, gpuFamily: NVIDIA_PRE_ADA.family};
|
||||||
|
const PROBE_SAYS_SOFTWARE = {
|
||||||
|
av1: 'software',
|
||||||
|
h265: 'software',
|
||||||
|
h264: 'software',
|
||||||
|
vp9: 'software',
|
||||||
|
vp8: 'software',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function h264Probes(): Array<EncodingInfoConfig> {
|
||||||
|
return encodingInfoConfigs.filter((config) => config.video.contentType.startsWith('video/H264'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the WebRTC encode probe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
encodingInfoConfigs.length = 0;
|
||||||
|
streamingMode = 'custom';
|
||||||
|
screenshareResolution = 'high';
|
||||||
|
videoFrameRate = 60;
|
||||||
|
higherVideoQuality = true;
|
||||||
|
shareSettingsUnavailable = false;
|
||||||
|
encodingInfoAnswer = () => ({supported: true, powerEfficient: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks about the single H.264 format the SFU can negotiate', async () => {
|
||||||
|
await probeWebRtcEncodeEfficiency();
|
||||||
|
expect(h264Probes().map((config) => config.video.contentType)).toEqual([
|
||||||
|
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||||
|
]);
|
||||||
|
expect(NEGOTIABLE_H264_PROBE_CONTENT_TYPE).toBe(
|
||||||
|
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes at the configured share size and frame rate', async () => {
|
||||||
|
await probeWebRtcEncodeEfficiency();
|
||||||
|
expect(h264Probes()[0]?.video).toMatchObject({width: 1920, height: 1080, framerate: 60, bitrate: 6_000_000});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the share settings down when the user picks a smaller share', async () => {
|
||||||
|
screenshareResolution = 'medium';
|
||||||
|
videoFrameRate = 30;
|
||||||
|
await probeWebRtcEncodeEfficiency();
|
||||||
|
expect(h264Probes()[0]?.video).toMatchObject({width: 1280, height: 720, framerate: 30, bitrate: 3_000_000});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers software for H.264 when the negotiated format is not power efficient', async () => {
|
||||||
|
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'software'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers hardware for H.264 when the negotiated format is power efficient', async () => {
|
||||||
|
encodingInfoAnswer = () => ({supported: true, powerEfficient: true});
|
||||||
|
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'hardware'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still answers at the largest share size when the share settings cannot be read', async () => {
|
||||||
|
shareSettingsUnavailable = true;
|
||||||
|
await expect(probeWebRtcEncodeEfficiency()).resolves.toMatchObject({h264: 'software'});
|
||||||
|
expect(h264Probes()[0]?.video).toMatchObject({width: 1920, height: 1080, framerate: 60, bitrate: 6_000_000});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reconciling the GPU table against the probe', () => {
|
||||||
|
it('demotes H.264 to software on Windows when the negotiated format is not power efficient', () => {
|
||||||
|
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'windows', PCI_VENDOR_AMD);
|
||||||
|
expect(report.h264).toBe('software');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demotes H.264 to software on macOS when the negotiated format is not power efficient', () => {
|
||||||
|
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'macos', PCI_VENDOR_APPLE);
|
||||||
|
expect(report.h264).toBe('software');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demotes H.264 to software when the probe could not answer at all', () => {
|
||||||
|
expect(reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, null, 'windows', PCI_VENDOR_AMD).h264).toBe('software');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hardware H.264 on Windows when the probe reports a power efficient encoder', () => {
|
||||||
|
const report = reconcileHardwareEncodeReport(
|
||||||
|
AMD_RDNA3_REPORT,
|
||||||
|
{...PROBE_SAYS_SOFTWARE, h264: 'hardware'},
|
||||||
|
'windows',
|
||||||
|
PCI_VENDOR_AMD,
|
||||||
|
);
|
||||||
|
expect(report.h264).toBe('hardware');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hardware H.264 on Linux when the probe reports a power efficient encoder', () => {
|
||||||
|
const report = reconcileHardwareEncodeReport(
|
||||||
|
NVIDIA_REPORT,
|
||||||
|
{...PROBE_SAYS_SOFTWARE, h264: 'hardware'},
|
||||||
|
'linux',
|
||||||
|
PCI_VENDOR_NVIDIA,
|
||||||
|
);
|
||||||
|
expect(report.h264).toBe('hardware');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the other codecs on the table verdict outside Linux NVIDIA', () => {
|
||||||
|
const report = reconcileHardwareEncodeReport(AMD_RDNA3_REPORT, PROBE_SAYS_SOFTWARE, 'windows', PCI_VENDOR_AMD);
|
||||||
|
expect(report.av1).toBe('hardware');
|
||||||
|
expect(report.h265).toBe('hardware');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,14 @@ import {
|
|||||||
isDesktop,
|
isDesktop,
|
||||||
type NativePlatform,
|
type NativePlatform,
|
||||||
} from '@app/features/ui/utils/NativeUtils';
|
} from '@app/features/ui/utils/NativeUtils';
|
||||||
|
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||||
|
import {
|
||||||
|
getScreenShareBitrateBps,
|
||||||
|
resolveEffectiveScreenShareDimensions,
|
||||||
|
resolveStreamingModeSettings,
|
||||||
|
SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
|
||||||
|
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||||
|
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||||
import type {GpuDeviceInfo, GpuInfo} from '@app/types/electron.d';
|
import type {GpuDeviceInfo, GpuInfo} from '@app/types/electron.d';
|
||||||
import type {VideoCodec} from 'livekit-client';
|
import type {VideoCodec} from 'livekit-client';
|
||||||
|
|
||||||
@@ -236,19 +244,52 @@ export function reportFromGpuInfo(info: GpuInfo): HardwareEncodeReport {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NEGOTIABLE_H264_PROBE_CONTENT_TYPE =
|
||||||
|
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f';
|
||||||
|
|
||||||
const WEBRTC_ENCODE_PROBE_CONTENT_TYPES: Record<VideoCodec, ReadonlyArray<string>> = {
|
const WEBRTC_ENCODE_PROBE_CONTENT_TYPES: Record<VideoCodec, ReadonlyArray<string>> = {
|
||||||
av1: ['video/AV1'],
|
av1: ['video/AV1'],
|
||||||
h265: ['video/H265'],
|
h265: ['video/H265'],
|
||||||
h264: [
|
h264: [NEGOTIABLE_H264_PROBE_CONTENT_TYPE],
|
||||||
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
|
||||||
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f',
|
|
||||||
'video/H264;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
|
||||||
'video/H264',
|
|
||||||
],
|
|
||||||
vp9: ['video/VP9'],
|
vp9: ['video/VP9'],
|
||||||
vp8: ['video/VP8'],
|
vp8: ['video/VP8'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EncodeProbeVideoConfig {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
bitrate: number;
|
||||||
|
framerate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ENCODE_PROBE_VIDEO_CONFIG: EncodeProbeVideoConfig = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
bitrate: SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS,
|
||||||
|
framerate: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveEncodeProbeVideoConfig(): EncodeProbeVideoConfig {
|
||||||
|
try {
|
||||||
|
const settings = resolveStreamingModeSettings(
|
||||||
|
VoiceSettings.getStreamingMode(),
|
||||||
|
VoiceSettings.getScreenshareResolution(),
|
||||||
|
VoiceSettings.getVideoFrameRate(),
|
||||||
|
hasHigherVideoQuality(),
|
||||||
|
);
|
||||||
|
const {width, height} = resolveEffectiveScreenShareDimensions(settings.resolution);
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bitrate: getScreenShareBitrateBps(settings.resolution, settings.frameRate),
|
||||||
|
framerate: settings.frameRate,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug('Share settings were unavailable, probing at the largest share size instead', {error});
|
||||||
|
return DEFAULT_ENCODE_PROBE_VIDEO_CONFIG;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface WebRtcEncodingInfoResult {
|
interface WebRtcEncodingInfoResult {
|
||||||
supported?: boolean;
|
supported?: boolean;
|
||||||
powerEfficient?: boolean;
|
powerEfficient?: boolean;
|
||||||
@@ -261,13 +302,14 @@ interface MediaCapabilitiesLike {
|
|||||||
async function probeCodecEncodeEfficiency(
|
async function probeCodecEncodeEfficiency(
|
||||||
mediaCapabilities: MediaCapabilitiesLike,
|
mediaCapabilities: MediaCapabilitiesLike,
|
||||||
contentTypes: ReadonlyArray<string>,
|
contentTypes: ReadonlyArray<string>,
|
||||||
|
video: EncodeProbeVideoConfig,
|
||||||
): Promise<HardwareEncodeAnswer> {
|
): Promise<HardwareEncodeAnswer> {
|
||||||
let sawSupported = false;
|
let sawSupported = false;
|
||||||
for (const contentType of contentTypes) {
|
for (const contentType of contentTypes) {
|
||||||
try {
|
try {
|
||||||
const info = await mediaCapabilities.encodingInfo?.({
|
const info = await mediaCapabilities.encodingInfo?.({
|
||||||
type: 'webrtc',
|
type: 'webrtc',
|
||||||
video: {contentType, width: 1920, height: 1080, bitrate: 2_500_000, framerate: 30},
|
video: {contentType, ...video},
|
||||||
});
|
});
|
||||||
if (!info?.supported) continue;
|
if (!info?.supported) continue;
|
||||||
sawSupported = true;
|
sawSupported = true;
|
||||||
@@ -282,8 +324,11 @@ export async function probeWebRtcEncodeEfficiency(): Promise<Record<VideoCodec,
|
|||||||
const mediaCapabilities = (navigator as Navigator & {mediaCapabilities?: MediaCapabilitiesLike}).mediaCapabilities;
|
const mediaCapabilities = (navigator as Navigator & {mediaCapabilities?: MediaCapabilitiesLike}).mediaCapabilities;
|
||||||
if (!mediaCapabilities?.encodingInfo) return null;
|
if (!mediaCapabilities?.encodingInfo) return null;
|
||||||
const codecs: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
const codecs: ReadonlyArray<VideoCodec> = ['av1', 'h265', 'h264', 'vp9', 'vp8'];
|
||||||
|
const video = resolveEncodeProbeVideoConfig();
|
||||||
const answers = await Promise.all(
|
const answers = await Promise.all(
|
||||||
codecs.map((codec) => probeCodecEncodeEfficiency(mediaCapabilities, WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec])),
|
codecs.map((codec) =>
|
||||||
|
probeCodecEncodeEfficiency(mediaCapabilities, WEBRTC_ENCODE_PROBE_CONTENT_TYPES[codec], video),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const result = {} as Record<VideoCodec, HardwareEncodeAnswer>;
|
const result = {} as Record<VideoCodec, HardwareEncodeAnswer>;
|
||||||
codecs.forEach((codec, index) => {
|
codecs.forEach((codec, index) => {
|
||||||
@@ -299,19 +344,21 @@ export function reconcileHardwareEncodeReport(
|
|||||||
vendorId: number,
|
vendorId: number,
|
||||||
): HardwareEncodeReport {
|
): HardwareEncodeReport {
|
||||||
const isNvidiaReport = vendorId === PCI_VENDOR_NVIDIA || report.gpuFamily?.startsWith('nvidia-') === true;
|
const isNvidiaReport = vendorId === PCI_VENDOR_NVIDIA || report.gpuFamily?.startsWith('nvidia-') === true;
|
||||||
if (platform !== 'linux' || !isNvidiaReport) return report;
|
|
||||||
const adjust = (codec: VideoCodec): HardwareEncodeAnswer => {
|
const adjust = (codec: VideoCodec): HardwareEncodeAnswer => {
|
||||||
if (report[codec] !== 'hardware') return report[codec];
|
if (report[codec] !== 'hardware') return report[codec];
|
||||||
return efficiency?.[codec] === 'hardware' ? 'hardware' : 'software';
|
return efficiency?.[codec] === 'hardware' ? 'hardware' : 'software';
|
||||||
};
|
};
|
||||||
return {
|
if (platform === 'linux' && isNvidiaReport) {
|
||||||
...report,
|
return {
|
||||||
av1: adjust('av1'),
|
...report,
|
||||||
h265: adjust('h265'),
|
av1: adjust('av1'),
|
||||||
h264: adjust('h264'),
|
h265: adjust('h265'),
|
||||||
vp9: adjust('vp9'),
|
h264: adjust('h264'),
|
||||||
vp8: adjust('vp8'),
|
vp9: adjust('vp9'),
|
||||||
};
|
vp8: adjust('vp8'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {...report, h264: adjust('h264')};
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedReport: HardwareEncodeReport | null = null;
|
let cachedReport: HardwareEncodeReport | null = null;
|
||||||
|
|||||||
Reference in new issue
Block a user