Files
fluxer/fluxer_app/src/features/voice/utils/ScreenShareOptions.ts
T

596 lines
21 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ScreenshareResolution, StreamingMode} from '@app/features/voice/state/VoiceSettings';
import type {
ScreenShareContentHint,
ScreenShareScalabilityModePreference,
} from '@app/features/voice/utils/CodecCapabilityDetector';
import {getH264HardwareProfilesSync} from '@app/features/voice/utils/GpuEncoderCapabilities';
import type {ScreenShareCaptureOptions, TrackPublishOptions, VideoCodec} from 'livekit-client';
const DIMENSIONS: Record<
ScreenshareResolution,
{
width: number;
height: number;
}
> = {
low_240p: {width: 426, height: 240},
low_480p: {width: 854, height: 480},
medium: {width: 1280, height: 720},
high: {width: 1920, height: 1080},
ultra: {width: 2560, height: 1440},
source: {width: 3840, height: 2160},
};
export const SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS = 9_000_000;
export const SUPPORTED_SCREEN_SHARE_FRAME_RATES = [15, 30, 60, 90, 120] as const;
export type SupportedScreenShareFrameRate = (typeof SUPPORTED_SCREEN_SHARE_FRAME_RATES)[number];
const SCREEN_SHARE_BITS_PER_PIXEL_PER_FRAME = 0.02;
const SCREEN_SHARE_MIN_MAINTAIN_FRAMERATE_BITRATE_BPS = 500_000;
const SOFTWARE_H264_MAX_RESOLUTION: ScreenshareResolution = 'medium';
const SOFTWARE_H264_MAX_FRAME_RATE: SupportedScreenShareFrameRate = 30;
const BITRATE_KBPS: Record<ScreenshareResolution, Record<SupportedScreenShareFrameRate, number>> = {
low_240p: {15: 300, 30: 500, 60: 700, 90: 700, 120: 700},
low_480p: {15: 1200, 30: 2000, 60: 3000, 90: 3000, 120: 3000},
medium: {15: 2000, 30: 3000, 60: 4500, 90: 4500, 120: 4500},
high: {15: 3000, 30: 4500, 60: 6000, 90: 6000, 120: 6000},
ultra: {15: 4000, 30: 5500, 60: 6000, 90: 6000, 120: 6000},
source: {15: 4500, 30: 6000, 60: 6000, 90: 6000, 120: 6000},
};
const BITRATE_RUNGS = [
'low_240p',
'low_480p',
'medium',
'high',
'ultra',
'source',
] as const satisfies ReadonlyArray<ScreenshareResolution>;
export function resolveScreenShareFrameRate(frameRate: number): SupportedScreenShareFrameRate {
if (frameRate >= 60) return 60;
if (frameRate >= 30) return 30;
return 15;
}
function getScreenShareRungPixels(resolution: ScreenshareResolution): number {
return DIMENSIONS[resolution].width * DIMENSIONS[resolution].height;
}
function resolveBitrateRungForPixels(pixels: number): ScreenshareResolution {
let rung: ScreenshareResolution = BITRATE_RUNGS[0];
for (const candidate of BITRATE_RUNGS) {
if (getScreenShareRungPixels(candidate) > pixels) break;
rung = candidate;
}
return rung;
}
function computeScreenShareBitrateBps(
pixels: number,
rung: ScreenshareResolution,
frameRate: SupportedScreenShareFrameRate,
): number {
const rungFloor = BITRATE_KBPS[rung][frameRate] * 1000;
const computed = Math.round(SCREEN_SHARE_BITS_PER_PIXEL_PER_FRAME * pixels * frameRate);
return Math.min(SCREEN_SHARE_MAX_VIDEO_BITRATE_BPS, Math.max(rungFloor, computed));
}
export function getScreenShareBitrateBps(
resolution: ScreenshareResolution,
frameRate: SupportedScreenShareFrameRate,
sourceDimensions?: {
width: number;
height: number;
} | null,
): number {
const fit = resolveEffectiveScreenShareDimensions(resolution, sourceDimensions);
return computeScreenShareBitrateBps(fit.width * fit.height, fit.rung, frameRate);
}
export const STREAMING_MODE_PRESETS: Record<
Exclude<StreamingMode, 'custom'>,
{
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
}
> = {
gaming: {resolution: 'ultra', frameRate: 60},
screenshare: {resolution: 'high', frameRate: 30},
};
const FREE_STREAMING_MODE_PRESETS: Record<
Exclude<StreamingMode, 'custom'>,
{
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
}
> = {
gaming: {resolution: 'medium', frameRate: 30},
screenshare: {resolution: 'medium', frameRate: 30},
};
export interface BuiltScreenShareOptions {
captureOptions: ScreenShareCaptureOptions;
publishOptions: TrackPublishOptions;
}
export interface ScreenShareBuildConfig {
resolution: ScreenshareResolution;
frameRate: number;
context: ScreenShareContext;
includeAudio: boolean;
contentHint?: ScreenShareCaptureOptions['contentHint'];
degradationPreference?: NonNullable<TrackPublishOptions['degradationPreference']>;
sourceDimensions?: {
width: number;
height: number;
};
preferredDisplaySurface?: 'window' | 'monitor';
useBrowserAudioPicker?: boolean;
}
type ScreenShareVideoOptions = NonNullable<Exclude<ScreenShareCaptureOptions['video'], true>> & {
cursor?: 'always' | 'motion' | 'never';
};
function resolveScreenShareCursorCapture(
preferredDisplaySurface?: ScreenShareBuildConfig['preferredDisplaySurface'],
): 'always' | 'never' {
return preferredDisplaySurface === 'window' ? 'never' : 'always';
}
function floorToEven(value: number): number {
return Math.max(2, Math.floor(value / 2) * 2);
}
export function resolveEffectiveScreenShareDimensions(
resolution: ScreenshareResolution,
sourceDimensions?: {
width: number;
height: number;
} | null,
): {
width: number;
height: number;
rung: ScreenshareResolution;
} {
const preset = DIMENSIONS[resolution];
if (!sourceDimensions || sourceDimensions.width <= 0 || sourceDimensions.height <= 0) {
return {width: preset.width, height: preset.height, rung: resolution};
}
const budget = getScreenShareRungPixels(resolution);
const sourcePixels = sourceDimensions.width * sourceDimensions.height;
if (sourcePixels <= budget) {
return {
width: sourceDimensions.width,
height: sourceDimensions.height,
rung: resolveBitrateRungForPixels(sourcePixels),
};
}
const scale = Math.sqrt(budget / sourcePixels);
return {
width: floorToEven(sourceDimensions.width * scale),
height: floorToEven(sourceDimensions.height * scale),
rung: resolution,
};
}
export function buildScreenShareOptions(config: ScreenShareBuildConfig): BuiltScreenShareOptions {
const {width, height, rung} = resolveEffectiveScreenShareDimensions(config.resolution, config.sourceDimensions);
const resolvedFrameRate = resolveScreenShareFrameRate(config.frameRate);
const maxBitrate = computeScreenShareBitrateBps(width * height, rung, resolvedFrameRate);
const video: ScreenShareVideoOptions = {
cursor: resolveScreenShareCursorCapture(config.preferredDisplaySurface),
...(config.preferredDisplaySurface ? {displaySurface: config.preferredDisplaySurface} : {}),
};
return {
captureOptions: {
audio: config.includeAudio,
...(config.contentHint ? {contentHint: config.contentHint} : {}),
...(config.includeAudio ? {restrictOwnAudio: true} : {}),
selfBrowserSurface: 'include',
monitorTypeSurfaces: config.preferredDisplaySurface === 'window' ? 'exclude' : 'include',
systemAudio: config.includeAudio && config.useBrowserAudioPicker ? 'include' : 'exclude',
windowAudio: config.includeAudio ? (config.useBrowserAudioPicker ? 'system' : 'window') : 'exclude',
resolution: {width, height, frameRate: resolvedFrameRate},
video,
},
publishOptions: {
degradationPreference: resolveScreenShareDegradationPreference({
context: config.context,
rung,
contentHint: config.contentHint,
maxBitrate,
degradationPreference: config.degradationPreference,
}),
screenShareEncoding: {maxBitrate, maxFramerate: resolvedFrameRate, priority: 'high'},
},
};
}
const FREE_TIER_FALLBACK_RESOLUTION: ScreenshareResolution = 'medium';
const FREE_TIER_RESOLUTIONS: ReadonlyArray<ScreenshareResolution> = ['low_480p', 'medium'];
const FREE_TIER_MAX_FRAME_RATE: SupportedScreenShareFrameRate = 30;
function isFreeTierResolution(resolution: ScreenshareResolution): boolean {
return FREE_TIER_RESOLUTIONS.includes(resolution);
}
function clampToFreeTier(
resolution: ScreenshareResolution,
frameRate: SupportedScreenShareFrameRate,
): {
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
} {
const cappedResolution: ScreenshareResolution = isFreeTierResolution(resolution)
? resolution
: FREE_TIER_FALLBACK_RESOLUTION;
const cappedFrameRate: SupportedScreenShareFrameRate =
frameRate > FREE_TIER_MAX_FRAME_RATE ? FREE_TIER_MAX_FRAME_RATE : frameRate;
return {resolution: cappedResolution, frameRate: cappedFrameRate};
}
export function resolveStreamingModeSettings(
mode: StreamingMode,
customResolution: ScreenshareResolution,
customFrameRate: number,
hasHigherQuality: boolean,
): {
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
} {
const resolved =
mode === 'custom'
? {resolution: customResolution, frameRate: resolveScreenShareFrameRate(customFrameRate)}
: (hasHigherQuality ? STREAMING_MODE_PRESETS : FREE_STREAMING_MODE_PRESETS)[mode];
if (hasHigherQuality) {
return resolved;
}
return clampToFreeTier(resolved.resolution, resolved.frameRate);
}
export type ScreenShareContext = 'display' | 'app' | 'device';
export function normaliseStreamingModeForContext(mode: StreamingMode, context: ScreenShareContext): StreamingMode {
if (context === 'device' && mode === 'screenshare') {
return 'gaming';
}
return mode;
}
export function normaliseResolutionForContext(
resolution: ScreenshareResolution,
context: ScreenShareContext,
hasHigherQuality: boolean,
): ScreenshareResolution {
if (!hasHigherQuality && !isFreeTierResolution(resolution)) {
return FREE_TIER_FALLBACK_RESOLUTION;
}
if (context === 'device' && resolution === 'source') {
return hasHigherQuality ? 'ultra' : FREE_TIER_FALLBACK_RESOLUTION;
}
return resolution;
}
const PREMIUM_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<ScreenshareResolution> = ['high', 'ultra', 'source'];
const SENT_PIXEL_FILL_RATIO = 0.98;
export interface ScreenShareQualityInput {
mode: StreamingMode;
storedResolution: ScreenshareResolution;
storedFrameRate: number;
entitled: boolean;
context: ScreenShareContext;
}
export interface ScreenShareTargetInput extends ScreenShareQualityInput {
sourceDimensions: {width: number; height: number} | null;
hintSetting: ScreenShareContentHint;
codec?: VideoCodec;
softwareEncoderClamp?: boolean;
}
export interface ScreenShareTarget {
mode: StreamingMode;
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
context: ScreenShareContext;
width: number;
height: number;
rung: ScreenshareResolution;
maxBitrate: number;
contentHint: ScreenShareCaptureOptions['contentHint'];
degradationPreference: NonNullable<TrackPublishOptions['degradationPreference']>;
softwareEncoderClamped: boolean;
presetOwned: boolean;
tierLimited: boolean;
deviceMapped: boolean;
}
export interface ScreenShareDegradationInput {
context: ScreenShareContext;
rung: ScreenshareResolution;
contentHint: ScreenShareCaptureOptions['contentHint'];
maxBitrate: number;
degradationPreference?: NonNullable<TrackPublishOptions['degradationPreference']>;
}
export function resolveScreenShareDegradationPreference(
target: ScreenShareDegradationInput,
): NonNullable<TrackPublishOptions['degradationPreference']> {
if (target.degradationPreference) return target.degradationPreference;
if (target.context === 'device') return 'balanced';
if (target.rung === 'source') return 'maintain-resolution';
if (target.contentHint === 'text' || target.contentHint === 'detail') return 'maintain-resolution';
if (target.maxBitrate < SCREEN_SHARE_MIN_MAINTAIN_FRAMERATE_BITRATE_BPS) return 'maintain-resolution';
return 'maintain-framerate';
}
function shouldClampToSoftwareH264(codec: VideoCodec | undefined): boolean {
if (codec !== 'h264') return false;
return getH264HardwareProfilesSync()?.profiles.size === 0;
}
function clampToSoftwareH264Budget(quality: {
mode: StreamingMode;
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
}): {
mode: StreamingMode;
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
} {
const cappedResolution =
BITRATE_RUNGS.indexOf(quality.resolution) > BITRATE_RUNGS.indexOf(SOFTWARE_H264_MAX_RESOLUTION)
? SOFTWARE_H264_MAX_RESOLUTION
: quality.resolution;
const cappedFrameRate =
quality.frameRate > SOFTWARE_H264_MAX_FRAME_RATE ? SOFTWARE_H264_MAX_FRAME_RATE : quality.frameRate;
return {mode: quality.mode, resolution: cappedResolution, frameRate: cappedFrameRate};
}
function resolveEffectiveScreenShareQuality(input: ScreenShareQualityInput): {
mode: StreamingMode;
resolution: ScreenshareResolution;
frameRate: SupportedScreenShareFrameRate;
} {
const mode = normaliseStreamingModeForContext(input.mode, input.context);
const resolution = normaliseResolutionForContext(input.storedResolution, input.context, input.entitled);
const settings = resolveStreamingModeSettings(mode, resolution, input.storedFrameRate, input.entitled);
return {mode, resolution: settings.resolution, frameRate: settings.frameRate};
}
function resolveScreenShareContentHintForMode(
mode: StreamingMode,
hintSetting: ScreenShareContentHint,
): ScreenShareCaptureOptions['contentHint'] {
if (mode === 'gaming') return 'motion';
if (mode === 'screenshare') return 'text';
return hintSetting === 'auto' ? undefined : hintSetting;
}
function resolveScreenShareContentHint(
mode: StreamingMode,
hintSetting: ScreenShareContentHint,
context: ScreenShareContext,
): ScreenShareCaptureOptions['contentHint'] {
const hint = resolveScreenShareContentHintForMode(mode, hintSetting);
return hint === 'motion' && context !== 'device' ? undefined : hint;
}
export function resolveScreenShareTarget(input: ScreenShareTargetInput): ScreenShareTarget {
const clamped = input.softwareEncoderClamp === true || shouldClampToSoftwareH264(input.codec);
const configured = resolveEffectiveScreenShareQuality(input);
const effective = clamped ? clampToSoftwareH264Budget(configured) : configured;
const configuredOnDisplay = resolveEffectiveScreenShareQuality({...input, context: 'display'});
const onDisplay = clamped ? clampToSoftwareH264Budget(configuredOnDisplay) : configuredOnDisplay;
const fit = resolveEffectiveScreenShareDimensions(effective.resolution, input.sourceDimensions);
const maxBitrate = computeScreenShareBitrateBps(fit.width * fit.height, fit.rung, effective.frameRate);
const contentHint = resolveScreenShareContentHint(effective.mode, input.hintSetting, input.context);
return {
mode: effective.mode,
resolution: effective.resolution,
frameRate: effective.frameRate,
context: input.context,
width: fit.width,
height: fit.height,
rung: fit.rung,
maxBitrate,
contentHint,
degradationPreference: resolveScreenShareDegradationPreference({
context: input.context,
rung: fit.rung,
contentHint,
maxBitrate,
}),
softwareEncoderClamped: clamped,
presetOwned: effective.mode !== 'custom',
tierLimited:
!input.entitled &&
input.mode === 'custom' &&
(PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(input.storedResolution) ||
resolveScreenShareFrameRate(input.storedFrameRate) > FREE_TIER_MAX_FRAME_RATE),
deviceMapped:
effective.mode !== onDisplay.mode ||
effective.resolution !== onDisplay.resolution ||
effective.frameRate !== onDisplay.frameRate,
};
}
export interface ScreenShareLayeringInput {
codec: VideoCodec | undefined;
svcSetting: ScreenShareScalabilityModePreference | undefined;
}
export interface ScreenShareLayering {
simulcast: boolean;
scalabilityMode: TrackPublishOptions['scalabilityMode'];
}
export function resolveScreenShareLayering(input: ScreenShareLayeringInput): ScreenShareLayering {
if (input.codec !== 'av1' && input.codec !== 'vp9') {
return {simulcast: false, scalabilityMode: undefined};
}
return {
simulcast: false,
scalabilityMode: input.svcSetting === 'single_layer' ? 'L1T1' : 'L1T3',
};
}
export type ScreenShareLimitClass = 'device' | 'link' | 'capture';
export interface ScreenShareLimitSample {
frameRate: SupportedScreenShareFrameRate;
maxBitrate: number;
elapsedMs: number;
framesEncoded: number;
encodeTimeMs: number;
bytesSent: number;
targetBitrateBps: number | null;
sourceFramesPerSecond: number | null;
cpuLimitedTicks: number;
}
const ENCODE_DUTY_SATURATION = 1;
const LINK_LIMITED_CPU_TICKS = 3;
const LINK_LIMITED_TARGET_RATIO = 0.85;
const LINK_LIMITED_FILL_RATIO = 0.65;
const CAPTURE_SHORTFALL_RATIO = 0.6;
export function classifyScreenShareLimit(sample: ScreenShareLimitSample): ScreenShareLimitClass | null {
if (sample.elapsedMs <= 0 || sample.framesEncoded < 0 || sample.bytesSent < 0) return null;
if (sample.framesEncoded === 0 && sample.sourceFramesPerSecond === null) return null;
const duty =
sample.framesEncoded > 0 ? (sample.encodeTimeMs / sample.framesEncoded) * (sample.frameRate / 1000) : null;
if (duty !== null && duty >= ENCODE_DUTY_SATURATION) return 'device';
if (
sample.sourceFramesPerSecond !== null &&
sample.sourceFramesPerSecond < sample.frameRate * CAPTURE_SHORTFALL_RATIO
) {
return 'capture';
}
if (sample.maxBitrate <= 0) return 'device';
const fill = (sample.bytesSent * 8000) / sample.elapsedMs / sample.maxBitrate;
const targetRatio = sample.targetBitrateBps === null ? 1 : sample.targetBitrateBps / sample.maxBitrate;
if (
sample.cpuLimitedTicks < LINK_LIMITED_CPU_TICKS &&
targetRatio < LINK_LIMITED_TARGET_RATIO &&
fill >= LINK_LIMITED_FILL_RATIO
) {
return 'link';
}
return 'device';
}
export interface ScreenShareSenderParametersInput {
encodings: ReadonlyArray<RTCRtpEncodingParameters>;
target: ScreenShareTarget;
capture: {width: number; height: number} | null;
scalabilityMode: TrackPublishOptions['scalabilityMode'];
}
export interface ScreenShareSenderParameters {
degradationPreference: NonNullable<TrackPublishOptions['degradationPreference']>;
encodings: Array<RTCRtpEncodingParameters>;
}
function distributeScreenShareBitrate(
encodings: ReadonlyArray<RTCRtpEncodingParameters>,
maxBitrate: number,
): Array<number> {
if (encodings.length === 1) return [maxBitrate];
const weights = encodings.map((encoding) =>
typeof encoding.maxBitrate === 'number' && encoding.maxBitrate > 0 ? encoding.maxBitrate : 0,
);
if (weights.some((weight) => weight <= 0)) {
return encodings.map(() => Math.floor(maxBitrate / encodings.length));
}
const total = weights.reduce((sum, weight) => sum + weight, 0);
return weights.map((weight) => Math.floor((weight / total) * maxBitrate));
}
export function resolveScreenShareSenderCodec(
codecOverride: VideoCodec | undefined,
negotiatedCodec: VideoCodec | undefined,
publishedCodec: VideoCodec | undefined,
): VideoCodec | undefined {
return codecOverride ?? negotiatedCodec ?? publishedCodec;
}
export function buildScreenShareSenderParameters(input: ScreenShareSenderParametersInput): ScreenShareSenderParameters {
const degradationPreference = resolveScreenShareDegradationPreference(input.target);
const targetPixels = input.target.width * input.target.height;
const capturePixels = input.capture ? input.capture.width * input.capture.height : null;
const baseScale = Math.min(...input.encodings.map((encoding) => encoding.scaleResolutionDownBy ?? 1));
const targetScale =
capturePixels === null || degradationPreference === 'maintain-framerate'
? 1
: Math.max(1, Math.sqrt(capturePixels / targetPixels));
const sentPixels = capturePixels === null ? null : capturePixels / (targetScale * targetScale);
const maxBitrate =
sentPixels !== null && sentPixels < targetPixels * SENT_PIXEL_FILL_RATIO
? Math.min(
input.target.maxBitrate,
computeScreenShareBitrateBps(sentPixels, resolveBitrateRungForPixels(sentPixels), input.target.frameRate),
)
: input.target.maxBitrate;
const bitrates = distributeScreenShareBitrate(input.encodings, maxBitrate);
return {
degradationPreference,
encodings: input.encodings.map((encoding, index) => {
const applied: RTCRtpEncodingParameters = {
...encoding,
maxBitrate: bitrates[index],
maxFramerate: input.target.frameRate,
priority: 'high',
networkPriority: 'high',
scaleResolutionDownBy: ((encoding.scaleResolutionDownBy ?? 1) / baseScale) * targetScale,
};
if (input.scalabilityMode) {
applied.scalabilityMode = input.scalabilityMode;
} else {
delete applied.scalabilityMode;
}
return applied;
}),
};
}
export type ScreenShareQualityPick =
| {axis: 'resolution'; resolution: ScreenshareResolution}
| {axis: 'frameRate'; frameRate: SupportedScreenShareFrameRate};
export interface ScreenShareQualityPatch {
streamingMode: 'custom';
screenshareResolution?: ScreenshareResolution;
videoFrameRate?: SupportedScreenShareFrameRate;
}
export function resolveScreenShareQualityPick(
input: ScreenShareQualityInput,
pick: ScreenShareQualityPick,
): ScreenShareQualityPatch | null {
const effective = resolveEffectiveScreenShareQuality(input);
const stored = resolveEffectiveScreenShareQuality({...input, mode: 'custom'});
if (pick.axis === 'resolution') {
if (normaliseResolutionForContext(pick.resolution, input.context, input.entitled) !== pick.resolution) return null;
if (pick.resolution === effective.resolution) return null;
return {
streamingMode: 'custom',
screenshareResolution: pick.resolution,
...(stored.frameRate === effective.frameRate ? {} : {videoFrameRate: effective.frameRate}),
};
}
if (!input.entitled && pick.frameRate > FREE_TIER_MAX_FRAME_RATE) return null;
if (pick.frameRate === effective.frameRate) return null;
return {
streamingMode: 'custom',
...(stored.resolution === effective.resolution ? {} : {screenshareResolution: effective.resolution}),
videoFrameRate: pick.frameRate,
};
}