From 390dc22c967eed2c99d8b7bf19d128bf6bd1a463 Mon Sep 17 00:00:00 2001 From: Ryan Emmick Date: Wed, 11 Feb 2026 01:43:56 -0600 Subject: [PATCH] feat: configurable media quality via config.json Add a `media_quality` section to config.json that allows self-hosters to configure video codec, resolution, bitrate, framerate, and simulcast layers for both camera and screen sharing. This addresses the long-standing request in #249 for configurable media quality settings. The LiveKit SDK already supports all of these options; this change exposes them through the existing config system. New config.json fields: - media_quality.video_codec: preferred codec (vp8/vp9/h264/av1) - media_quality.video: camera resolution, bitrate, framerate, simulcast layers - media_quality.screen_share: screen share resolution, bitrate, framerate, simulcast layers (enables 3+ layer simulcast for screen sharing) All fields are optional and fall back to the existing defaults (VP8, 720p camera, 1080p screen share) when not specified. Signed-off-by: Ryan Emmick --- src/config/ConfigOptions.ts | 55 ++++++ src/livekit/options.ts | 171 ++++++++++++++---- .../CallViewModel/localMember/LocalMember.ts | 10 + .../remoteMembers/ConnectionFactory.ts | 12 +- 4 files changed, 212 insertions(+), 36 deletions(-) diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 1b120546c..914efbd2b 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -85,6 +85,61 @@ export interface ConfigOptions { */ ssla?: string; + /** + * Media quality settings for video and screen sharing. + * These override the hardcoded LiveKit defaults. + */ + media_quality?: { + /** + * Video codec preference. The server must also have the codec enabled. + * @default "vp8" + */ + video_codec?: "vp8" | "vp9" | "h264" | "av1"; + + /** + * Camera video settings. + */ + video?: { + /** Max resolution height in pixels (e.g. 720, 1080, 1440). @default 720 */ + max_resolution?: number; + /** Max bitrate in bits per second. @default 1700000 */ + max_bitrate?: number; + /** Max framerate. @default 30 */ + max_framerate?: number; + /** + * Simulcast layers as an array of {height, bitrate} objects, + * ordered from lowest to highest quality. + * @default [{height: 180, bitrate: 160000}, {height: 360, bitrate: 450000}] + */ + simulcast_layers?: Array<{ + height: number; + bitrate: number; + }>; + }; + + /** + * Screen share settings. + */ + screen_share?: { + /** Max resolution height in pixels. @default 1080 */ + max_resolution?: number; + /** Max bitrate in bits per second. @default 5000000 */ + max_bitrate?: number; + /** Max framerate. @default 30 */ + max_framerate?: number; + /** + * Simulcast layers for screen sharing as an array of {height, bitrate, framerate} objects, + * ordered from lowest to highest quality. If omitted, LiveKit SDK defaults apply (1 extra + * layer at half resolution). + */ + simulcast_layers?: Array<{ + height: number; + bitrate: number; + framerate?: number; + }>; + }; + }; + media_devices?: { /** * Defines whether participants should start with audio enabled by default. diff --git a/src/livekit/options.ts b/src/livekit/options.ts index 1d4cad774..8b98d6113 100644 --- a/src/livekit/options.ts +++ b/src/livekit/options.ts @@ -13,42 +13,149 @@ import { type TrackPublishDefaults, type VideoPreset, VideoPresets, + VideoPreset as VideoPresetClass, } from "livekit-client"; -const defaultLiveKitPublishOptions: TrackPublishDefaults = { - audioPreset: AudioPresets.music, - dtx: true, - // disable red because the livekit server strips out red packets for clients - // that don't support it (firefox) but of course that doesn't work with e2ee. - red: false, - forceStereo: false, - simulcast: true, - videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[], - screenShareEncoding: ScreenSharePresets.h1080fps30.encoding, - stopMicTrackOnMute: false, - videoCodec: "vp8", - videoEncoding: VideoPresets.h720.encoding, - backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding }, -} as const; +import { Config } from "../config/Config"; +import type { ConfigOptions } from "../config/ConfigOptions"; -export const defaultLiveKitOptions: RoomOptions = { - // automatically manage subscribed video quality - adaptiveStream: true, +/** + * Find the closest matching VideoPreset for a given height. + */ +function videoPresetForHeight(height: number): VideoPreset { + if (height <= 180) return VideoPresets.h180; + if (height <= 360) return VideoPresets.h360; + if (height <= 540) return VideoPresets.h540; + if (height <= 720) return VideoPresets.h720; + if (height <= 1080) return VideoPresets.h1080; + if (height <= 1440) return VideoPresets.h1440; + return VideoPresets.h2160; +} - // optimize publishing bandwidth and CPU for published tracks - dynacast: true, +/** + * Build LiveKit publish options from config, falling back to sensible defaults. + */ +function buildPublishOptions( + mediaQuality: ConfigOptions["media_quality"], +): TrackPublishDefaults { + const videoConf = mediaQuality?.video; + const screenConf = mediaQuality?.screen_share; + const codec = mediaQuality?.video_codec ?? "vp8"; - // capture settings - videoCaptureDefaults: { - resolution: VideoPresets.h720.resolution, - }, + // Camera video encoding + const videoHeight = videoConf?.max_resolution ?? 720; + const basePreset = videoPresetForHeight(videoHeight); + const videoEncoding = { + maxBitrate: videoConf?.max_bitrate ?? basePreset.encoding.maxBitrate, + maxFramerate: videoConf?.max_framerate ?? basePreset.encoding.maxFramerate, + }; - // publish settings - publishDefaults: defaultLiveKitPublishOptions, + // Camera simulcast layers + let videoSimulcastLayers: VideoPreset[]; + if (videoConf?.simulcast_layers) { + videoSimulcastLayers = videoConf.simulcast_layers.map( + (layer) => + new VideoPresetClass( + Math.round((layer.height * 16) / 9), + layer.height, + layer.bitrate, + videoConf?.max_framerate ?? 30, + ), + ); + } else { + videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360]; + } - // default LiveKit options that seem to be sane - stopLocalTrackOnUnpublish: true, - reconnectPolicy: new DefaultReconnectPolicy(), - disconnectOnPageLeave: true, - webAudioMix: false, -}; + // Screen share encoding + const screenHeight = screenConf?.max_resolution ?? 1080; + const screenBasePreset = + screenHeight <= 720 + ? ScreenSharePresets.h720fps30 + : ScreenSharePresets.h1080fps30; + const screenShareEncoding = { + maxBitrate: screenConf?.max_bitrate ?? screenBasePreset.encoding.maxBitrate, + maxFramerate: + screenConf?.max_framerate ?? screenBasePreset.encoding.maxFramerate, + }; + + // Screen share simulcast layers + let screenShareSimulcastLayers: VideoPreset[] | undefined; + if (screenConf?.simulcast_layers) { + screenShareSimulcastLayers = screenConf.simulcast_layers.map( + (layer) => + new VideoPresetClass( + Math.round((layer.height * 16) / 9), + layer.height, + layer.bitrate, + layer.framerate ?? screenConf?.max_framerate ?? 30, + ), + ); + } + + return { + audioPreset: AudioPresets.music, + dtx: true, + // disable red because the livekit server strips out red packets for clients + // that don't support it (firefox) but of course that doesn't work with e2ee. + red: false, + forceStereo: false, + simulcast: true, + videoSimulcastLayers: videoSimulcastLayers as VideoPreset[], + screenShareEncoding, + ...(screenShareSimulcastLayers && { + screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[], + }), + stopMicTrackOnMute: false, + videoCodec: codec, + videoEncoding, + backupCodec: { codec: "vp8", encoding: videoEncoding }, + } as TrackPublishDefaults; +} + +/** + * Build LiveKit RoomOptions from config. + * Call this after Config.init() has resolved. + */ +export function buildLiveKitOptions( + mediaQuality?: ConfigOptions["media_quality"], +): RoomOptions { + const videoHeight = mediaQuality?.video?.max_resolution ?? 720; + const basePreset = videoPresetForHeight(videoHeight); + + return { + // automatically manage subscribed video quality + adaptiveStream: true, + + // optimize publishing bandwidth and CPU for published tracks + dynacast: true, + + // capture settings + videoCaptureDefaults: { + resolution: basePreset.resolution, + }, + + // publish settings + publishDefaults: buildPublishOptions(mediaQuality), + + // default LiveKit options that seem to be sane + stopLocalTrackOnUnpublish: true, + reconnectPolicy: new DefaultReconnectPolicy(), + disconnectOnPageLeave: true, + webAudioMix: false, + }; +} + +/** + * Get LiveKit options, reading from the loaded Config singleton. + * Falls back to defaults if Config is not yet initialized. + */ +export function getLiveKitOptions(): RoomOptions { + try { + return buildLiveKitOptions(Config.get().media_quality); + } catch { + return buildLiveKitOptions(); + } +} + +// Keep backward-compatible export for existing consumers +export const defaultLiveKitOptions: RoomOptions = buildLiveKitOptions(); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 10a007674..5cbe855a9 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -12,6 +12,8 @@ import { type ScreenShareCaptureOptions, RoomEvent, MediaDeviceFailure, + type ScreenSharePreset, + VideoPreset as VideoPresetClass, } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { @@ -661,6 +663,7 @@ export const createLocalMembership$ = ({ !getUrlParams().hideScreensharing ) { toggleScreenSharing = (): void => { + const screenConf = Config.get().media_quality?.screen_share; const screenshareSettings: ScreenShareCaptureOptions = { // Screen share audio shouldn't have any filtering. // "echoCancellation" is purposely excluded, as setting it to @@ -674,6 +677,13 @@ export const createLocalMembership$ = ({ selfBrowserSurface: "include", surfaceSwitching: "include", systemAudio: "include", + ...(screenConf?.max_resolution && { + resolution: { + width: Math.round((screenConf.max_resolution * 16) / 9), + height: screenConf.max_resolution, + frameRate: screenConf.max_framerate ?? 30, + }, + }), }; const targetScreenshareState = !sharingScreen$.value; logger.info( diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 38a09898b..fb26d1780 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -27,7 +27,10 @@ import type { import type { MediaDevices } from "../../MediaDevices.ts"; import type { Behavior } from "../../Behavior.ts"; import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"; -import { defaultLiveKitOptions } from "../../../livekit/options.ts"; +import { + defaultLiveKitOptions, + getLiveKitOptions, +} from "../../../livekit/options.ts"; // TODO evaluate if this should be done like the Publisher Factory export interface ConnectionFactory { @@ -138,15 +141,16 @@ function generateRoomOption({ echoCancellation: boolean; noiseSuppression: boolean; }): RoomOptions { + const liveKitOptions = getLiveKitOptions(); return { - ...defaultLiveKitOptions, + ...liveKitOptions, videoCaptureDefaults: { - ...defaultLiveKitOptions.videoCaptureDefaults, + ...liveKitOptions.videoCaptureDefaults, deviceId: devices.videoInput.selected$.value?.id, processor: processorState.processor, }, audioCaptureDefaults: { - ...defaultLiveKitOptions.audioCaptureDefaults, + ...liveKitOptions.audioCaptureDefaults, deviceId: devices.audioInput.selected$.value?.id, echoCancellation, noiseSuppression,