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 <ryanemmick4@gmail.com>
This commit is contained in:
Ryan Emmick
2026-02-11 01:43:56 -06:00
parent 90ba38d2bc
commit 390dc22c96
4 changed files with 212 additions and 36 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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(

View File

@@ -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,