Skip to content
1 change: 1 addition & 0 deletions .changes/clamp-simulcast-lower-layers
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Clamp simulcast lower layers to the top layer"
94 changes: 73 additions & 21 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -312,18 +312,83 @@ class Utils {
if (i >= videoRids.length) {
return;
}
final size = dimensions.min();
final size = dimensions.max();
final rid = videoRids[i];
if (e.encoding != null) {
result.add(e.encoding!.toRTCRtpEncoding(
rid: rid,
scaleResolutionDownBy: math.max(1, size / e.dimensions.min()),
scaleResolutionDownBy: math.max(1, size / e.dimensions.max()),
));
}
});
return result;
}

@internal
static List<VideoParameters> computeSimulcastPresets({
required VideoDimensions dimensions,
required VideoParameters original,
required List<VideoParameters> requestedPresets,
required bool isScreenShare,
}) {
final params = (requestedPresets.isNotEmpty
? requestedPresets
: _computeDefaultSimulcastParams(isScreenShare: isScreenShare, original: original))
.sorted();

if (params.isEmpty) {
return [original];
}
final lowPreset = params.first;
final midPreset = params.length > 1 ? params[1] : null;

final size = dimensions.max();
if (size >= 960 && midPreset != null) {
return [
_clampSimulcastPreset(lowPreset, to: original, inDimensions: dimensions),
_clampSimulcastPreset(midPreset, to: original, inDimensions: dimensions),
original,
];
}
if (size >= 480) {
return [
_clampSimulcastPreset(lowPreset, to: original, inDimensions: dimensions),
original,
];
}
return [original];
}

static VideoParameters _clampSimulcastPreset(
VideoParameters preset, {
required VideoParameters to,
required VideoDimensions inDimensions,
}) {
final presetEncoding = preset.encoding;
final topEncoding = to.encoding;
if (presetEncoding == null || topEncoding == null) {
return preset;
}

final rawScaleDownBy = inDimensions.max() / preset.dimensions.max();
final clampedFramerate = math.min(presetEncoding.maxFramerate, topEncoding.maxFramerate);
final clampedBitrate =
rawScaleDownBy <= 1.0 ? math.min(presetEncoding.maxBitrate, topEncoding.maxBitrate) : presetEncoding.maxBitrate;

if (clampedFramerate == presetEncoding.maxFramerate && clampedBitrate == presetEncoding.maxBitrate) {
return preset;
}

return VideoParameters(
description: preset.description,
dimensions: preset.dimensions,
encoding: presetEncoding.copyWith(
maxFramerate: clampedFramerate,
maxBitrate: clampedBitrate,
),
);
}

@internal
static FutureOr<String> getNetworkType() async {
if (!kIsWeb && lkPlatformIsTest()) {
Expand Down Expand Up @@ -451,25 +516,12 @@ class Utils {
// compute simulcast encodings
final userParams = isScreenShare ? options.screenShareSimulcastLayers : options.videoSimulcastLayers;

final params = (userParams.isNotEmpty
? userParams
: _computeDefaultSimulcastParams(isScreenShare: isScreenShare, original: original))
.sorted();

final VideoParameters lowPreset = params.first;
VideoParameters? midPreset;
if (params.length > 1) {
midPreset = params[1];
}

final size = dimensions.max();
List<VideoParameters> computedParams = [original];

if (size >= 960 && midPreset != null) {
computedParams = [lowPreset, midPreset, original];
} else if (size >= 480) {
computedParams = [lowPreset, original];
}
final computedParams = computeSimulcastPresets(
dimensions: dimensions,
original: original,
requestedPresets: userParams,
isScreenShare: isScreenShare,
);

return encodingsFromPresets(
dimensions,
Expand Down
183 changes: 174 additions & 9 deletions test/utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;

import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_client/src/utils.dart';

void main() {
Expand Down Expand Up @@ -57,22 +57,187 @@ void main() {
);
});

group('simulcast encodings', () {
test('same-resolution lower layer is clamped to top encoding', () {
final presets = Utils.computeSimulcastPresets(
dimensions: const VideoDimensions(1280, 720),
original: const VideoParameters(
dimensions: VideoDimensions(1280, 720),
encoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
),
requestedPresets: const [
VideoParametersPresets.h720_169,
VideoParametersPresets.h360_169,
],
isScreenShare: false,
);

expect(presets, hasLength(3));

expect(presets[0], VideoParametersPresets.h360_169);

expect(presets[1].dimensions, VideoDimensionsPresets.h720_169);
expect(presets[1].encoding?.maxFramerate, 24);
expect(presets[1].encoding?.maxBitrate, 1500000);

expect(presets[2].dimensions, const VideoDimensions(1280, 720));
expect(presets[2].encoding?.maxFramerate, 24);
expect(presets[2].encoding?.maxBitrate, 1500000);
});

test('lower-resolution layer clamps framerate but preserves preset bitrate', () {
final presets = Utils.computeSimulcastPresets(
dimensions: const VideoDimensions(1280, 720),
original: const VideoParameters(
dimensions: VideoDimensions(1280, 720),
encoding: VideoEncoding(maxBitrate: 500000, maxFramerate: 15),
),
requestedPresets: const [],
isScreenShare: false,
);

expect(presets, hasLength(3));
expect(presets[1].dimensions, VideoDimensionsPresets.h360_169);
expect(presets[1].encoding?.maxFramerate, 15);
expect(presets[1].encoding?.maxBitrate, 450000);
});

test('same-resolution full clamp', () {
final presets = Utils.computeSimulcastPresets(
dimensions: const VideoDimensions(854, 480),
original: const VideoParameters(
dimensions: VideoDimensions(854, 480),
encoding: VideoEncoding(maxBitrate: 600000, maxFramerate: 15),
),
requestedPresets: const [
VideoParameters(
dimensions: VideoDimensions(854, 480),
encoding: VideoEncoding(maxBitrate: 2000000, maxFramerate: 30),
),
],
isScreenShare: false,
);

expect(presets, hasLength(2));
expect(presets[0].encoding?.maxFramerate, 15);
expect(presets[0].encoding?.maxBitrate, 600000);
expect(presets[1].dimensions, const VideoDimensions(854, 480));
expect(presets[1].encoding?.maxFramerate, 15);
expect(presets[1].encoding?.maxBitrate, 600000);
});

test('ladder length follows the larger output dimension', () {
const cases = [
(VideoDimensions(320, 240), 1),
(VideoDimensions(640, 480), 2),
(VideoDimensions(1280, 720), 3),
];

for (final (dimensions, expectedCount) in cases) {
final presets = Utils.computeSimulcastPresets(
dimensions: dimensions,
original: VideoParameters(
dimensions: dimensions,
encoding: const VideoEncoding(maxBitrate: 1000000, maxFramerate: 30),
),
requestedPresets: const [],
isScreenShare: false,
);

expect(presets, hasLength(expectedCount), reason: 'dimensions=$dimensions');
}
});

test("presets that don't overshoot are passed through unchanged", () {
const original = VideoParameters(
dimensions: VideoDimensions(1920, 1080),
encoding: VideoEncoding(maxBitrate: 5000000, maxFramerate: 30),
);

final presets = Utils.computeSimulcastPresets(
dimensions: const VideoDimensions(1920, 1080),
original: original,
requestedPresets: const [
VideoParametersPresets.h360_169,
VideoParametersPresets.h720_169,
],
isScreenShare: false,
);

expect(presets, hasLength(3));
expect(presets[0], VideoParametersPresets.h360_169);
expect(presets[1], VideoParametersPresets.h720_169);
expect(presets[2], original);
});

test('clamped layer carries forward per-layer priorities', () {
const prioritized = VideoParameters(
dimensions: VideoDimensions(1280, 720),
encoding: VideoEncoding(
maxBitrate: 1700000,
maxFramerate: 30,
bitratePriority: Priority.high,
networkPriority: Priority.high,
),
);

final presets = Utils.computeSimulcastPresets(
dimensions: const VideoDimensions(1280, 720),
original: const VideoParameters(
dimensions: VideoDimensions(1280, 720),
encoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
),
requestedPresets: const [
VideoParametersPresets.h360_169,
prioritized,
],
isScreenShare: false,
);

expect(presets, hasLength(3));
expect(presets[1].encoding?.maxFramerate, 24);
expect(presets[1].encoding?.maxBitrate, 1500000);
expect(presets[1].encoding?.bitratePriority, Priority.high);
expect(presets[1].encoding?.networkPriority, Priority.high);
});

test('computed encodings use clamped presets', () {
final encodings = Utils.computeVideoEncodings(
isScreenShare: false,
dimensions: const VideoDimensions(1280, 720),
options: const VideoPublishOptions(
videoEncoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
videoSimulcastLayers: [
VideoParametersPresets.h720_169,
VideoParametersPresets.h360_169,
],
),
);

expect(encodings, hasLength(3));
expect(encodings![1].rid, 'h');
expect(encodings[1].maxFramerate, 24);
expect(encodings[1].maxBitrate, 1500000);
expect(encodings[1].scaleResolutionDownBy, 1);
});
});

group('screen share simulcast encodings', () {
test('screen share preset bitrates match common SDK presets', () {
expect(lk.VideoParametersPresets.screenShareH720FPS5.encoding?.maxBitrate, 800000);
expect(lk.VideoParametersPresets.screenShareH1080FPS30.encoding?.maxBitrate, 5000000);
expect(VideoParametersPresets.screenShareH720FPS5.encoding?.maxBitrate, 800000);
expect(VideoParametersPresets.screenShareH1080FPS30.encoding?.maxBitrate, 5000000);
});

test('default lower layer follows top layer fps and priorities', () {
final encodings = Utils.computeVideoEncodings(
isScreenShare: true,
dimensions: const lk.VideoDimensions(1920, 1080),
options: const lk.VideoPublishOptions(
screenShareEncoding: lk.VideoEncoding(
dimensions: const VideoDimensions(1920, 1080),
options: const VideoPublishOptions(
screenShareEncoding: VideoEncoding(
maxBitrate: 2500000,
maxFramerate: 15,
bitratePriority: lk.Priority.high,
networkPriority: lk.Priority.high,
bitratePriority: Priority.high,
networkPriority: Priority.high,
),
),
);
Expand All @@ -91,7 +256,7 @@ void main() {
test('default lower layer follows selected screen share preset', () {
final encodings = Utils.computeVideoEncodings(
isScreenShare: true,
dimensions: const lk.VideoDimensions(1920, 1080),
dimensions: const VideoDimensions(1920, 1080),
);

expect(encodings, hasLength(2));
Expand Down
Loading