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:
@@ -85,6 +85,61 @@ export interface ConfigOptions {
|
|||||||
*/
|
*/
|
||||||
ssla?: string;
|
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?: {
|
media_devices?: {
|
||||||
/**
|
/**
|
||||||
* Defines whether participants should start with audio enabled by default.
|
* Defines whether participants should start with audio enabled by default.
|
||||||
|
|||||||
+139
-32
@@ -13,42 +13,149 @@ import {
|
|||||||
type TrackPublishDefaults,
|
type TrackPublishDefaults,
|
||||||
type VideoPreset,
|
type VideoPreset,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
|
VideoPreset as VideoPresetClass,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
|
import { Config } from "../config/Config";
|
||||||
audioPreset: AudioPresets.music,
|
import type { ConfigOptions } from "../config/ConfigOptions";
|
||||||
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;
|
|
||||||
|
|
||||||
export const defaultLiveKitOptions: RoomOptions = {
|
/**
|
||||||
// automatically manage subscribed video quality
|
* Find the closest matching VideoPreset for a given height.
|
||||||
adaptiveStream: true,
|
*/
|
||||||
|
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
|
// Camera video encoding
|
||||||
videoCaptureDefaults: {
|
const videoHeight = videoConf?.max_resolution ?? 720;
|
||||||
resolution: VideoPresets.h720.resolution,
|
const basePreset = videoPresetForHeight(videoHeight);
|
||||||
},
|
const videoEncoding = {
|
||||||
|
maxBitrate: videoConf?.max_bitrate ?? basePreset.encoding.maxBitrate,
|
||||||
|
maxFramerate: videoConf?.max_framerate ?? basePreset.encoding.maxFramerate,
|
||||||
|
};
|
||||||
|
|
||||||
// publish settings
|
// Camera simulcast layers
|
||||||
publishDefaults: defaultLiveKitPublishOptions,
|
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
|
// Screen share encoding
|
||||||
stopLocalTrackOnUnpublish: true,
|
const screenHeight = screenConf?.max_resolution ?? 1080;
|
||||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
const screenBasePreset =
|
||||||
disconnectOnPageLeave: true,
|
screenHeight <= 720
|
||||||
webAudioMix: false,
|
? 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();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
type ScreenShareCaptureOptions,
|
type ScreenShareCaptureOptions,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
|
type ScreenSharePreset,
|
||||||
|
VideoPreset as VideoPresetClass,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { observeParticipantEvents } from "@livekit/components-core";
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
@@ -661,6 +663,7 @@ export const createLocalMembership$ = ({
|
|||||||
!getUrlParams().hideScreensharing
|
!getUrlParams().hideScreensharing
|
||||||
) {
|
) {
|
||||||
toggleScreenSharing = (): void => {
|
toggleScreenSharing = (): void => {
|
||||||
|
const screenConf = Config.get().media_quality?.screen_share;
|
||||||
const screenshareSettings: ScreenShareCaptureOptions = {
|
const screenshareSettings: ScreenShareCaptureOptions = {
|
||||||
// Screen share audio shouldn't have any filtering.
|
// Screen share audio shouldn't have any filtering.
|
||||||
// "echoCancellation" is purposely excluded, as setting it to
|
// "echoCancellation" is purposely excluded, as setting it to
|
||||||
@@ -674,6 +677,13 @@ export const createLocalMembership$ = ({
|
|||||||
selfBrowserSurface: "include",
|
selfBrowserSurface: "include",
|
||||||
surfaceSwitching: "include",
|
surfaceSwitching: "include",
|
||||||
systemAudio: "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;
|
const targetScreenshareState = !sharingScreen$.value;
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import type {
|
|||||||
import type { MediaDevices } from "../../MediaDevices.ts";
|
import type { MediaDevices } from "../../MediaDevices.ts";
|
||||||
import type { Behavior } from "../../Behavior.ts";
|
import type { Behavior } from "../../Behavior.ts";
|
||||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
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
|
// TODO evaluate if this should be done like the Publisher Factory
|
||||||
export interface ConnectionFactory {
|
export interface ConnectionFactory {
|
||||||
@@ -138,15 +141,16 @@ function generateRoomOption({
|
|||||||
echoCancellation: boolean;
|
echoCancellation: boolean;
|
||||||
noiseSuppression: boolean;
|
noiseSuppression: boolean;
|
||||||
}): RoomOptions {
|
}): RoomOptions {
|
||||||
|
const liveKitOptions = getLiveKitOptions();
|
||||||
return {
|
return {
|
||||||
...defaultLiveKitOptions,
|
...liveKitOptions,
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
...liveKitOptions.videoCaptureDefaults,
|
||||||
deviceId: devices.videoInput.selected$.value?.id,
|
deviceId: devices.videoInput.selected$.value?.id,
|
||||||
processor: processorState.processor,
|
processor: processorState.processor,
|
||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
...liveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
deviceId: devices.audioInput.selected$.value?.id,
|
||||||
echoCancellation,
|
echoCancellation,
|
||||||
noiseSuppression,
|
noiseSuppression,
|
||||||
|
|||||||
Reference in New Issue
Block a user