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

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