Skip to content

Commit d83f272

Browse files
authored
Clamp simulcast lower layers to top layer (#1084)
## Summary - Port Swift SDK simulcast lower-layer clamping behavior to Flutter. - Clamp lower-layer max framerate so it does not exceed the configured top layer. - Clamp lower-layer bitrate only when the layer does not actually downscale resolution. - Use the larger output dimension when computing simulcast scale-down values. - Add coverage for clamp behavior, ladder length, unchanged presets, priority preservation, and computed encodings. ## Testing - `dart format --output=none --set-exit-if-changed lib/src/types/video_parameters.dart lib/src/utils.dart test/utils_test.dart` - `flutter test test/utils_test.dart` - `flutter analyze --no-pub lib/src/types/video_parameters.dart lib/src/utils.dart test/utils_test.dart`
1 parent 55295de commit d83f272

3 files changed

Lines changed: 248 additions & 30 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Clamp simulcast lower layers to the top layer"

lib/src/utils.dart

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -312,18 +312,83 @@ class Utils {
312312
if (i >= videoRids.length) {
313313
return;
314314
}
315-
final size = dimensions.min();
315+
final size = dimensions.max();
316316
final rid = videoRids[i];
317317
if (e.encoding != null) {
318318
result.add(e.encoding!.toRTCRtpEncoding(
319319
rid: rid,
320-
scaleResolutionDownBy: math.max(1, size / e.dimensions.min()),
320+
scaleResolutionDownBy: math.max(1, size / e.dimensions.max()),
321321
));
322322
}
323323
});
324324
return result;
325325
}
326326

327+
@internal
328+
static List<VideoParameters> computeSimulcastPresets({
329+
required VideoDimensions dimensions,
330+
required VideoParameters original,
331+
required List<VideoParameters> requestedPresets,
332+
required bool isScreenShare,
333+
}) {
334+
final params = (requestedPresets.isNotEmpty
335+
? requestedPresets
336+
: _computeDefaultSimulcastParams(isScreenShare: isScreenShare, original: original))
337+
.sorted();
338+
339+
if (params.isEmpty) {
340+
return [original];
341+
}
342+
final lowPreset = params.first;
343+
final midPreset = params.length > 1 ? params[1] : null;
344+
345+
final size = dimensions.max();
346+
if (size >= 960 && midPreset != null) {
347+
return [
348+
_clampSimulcastPreset(lowPreset, to: original, inDimensions: dimensions),
349+
_clampSimulcastPreset(midPreset, to: original, inDimensions: dimensions),
350+
original,
351+
];
352+
}
353+
if (size >= 480) {
354+
return [
355+
_clampSimulcastPreset(lowPreset, to: original, inDimensions: dimensions),
356+
original,
357+
];
358+
}
359+
return [original];
360+
}
361+
362+
static VideoParameters _clampSimulcastPreset(
363+
VideoParameters preset, {
364+
required VideoParameters to,
365+
required VideoDimensions inDimensions,
366+
}) {
367+
final presetEncoding = preset.encoding;
368+
final topEncoding = to.encoding;
369+
if (presetEncoding == null || topEncoding == null) {
370+
return preset;
371+
}
372+
373+
final rawScaleDownBy = inDimensions.max() / preset.dimensions.max();
374+
final clampedFramerate = math.min(presetEncoding.maxFramerate, topEncoding.maxFramerate);
375+
final clampedBitrate =
376+
rawScaleDownBy <= 1.0 ? math.min(presetEncoding.maxBitrate, topEncoding.maxBitrate) : presetEncoding.maxBitrate;
377+
378+
if (clampedFramerate == presetEncoding.maxFramerate && clampedBitrate == presetEncoding.maxBitrate) {
379+
return preset;
380+
}
381+
382+
return VideoParameters(
383+
description: preset.description,
384+
dimensions: preset.dimensions,
385+
encoding: presetEncoding.copyWith(
386+
maxFramerate: clampedFramerate,
387+
maxBitrate: clampedBitrate,
388+
),
389+
);
390+
}
391+
327392
@internal
328393
static FutureOr<String> getNetworkType() async {
329394
if (!kIsWeb && lkPlatformIsTest()) {
@@ -451,25 +516,12 @@ class Utils {
451516
// compute simulcast encodings
452517
final userParams = isScreenShare ? options.screenShareSimulcastLayers : options.videoSimulcastLayers;
453518

454-
final params = (userParams.isNotEmpty
455-
? userParams
456-
: _computeDefaultSimulcastParams(isScreenShare: isScreenShare, original: original))
457-
.sorted();
458-
459-
final VideoParameters lowPreset = params.first;
460-
VideoParameters? midPreset;
461-
if (params.length > 1) {
462-
midPreset = params[1];
463-
}
464-
465-
final size = dimensions.max();
466-
List<VideoParameters> computedParams = [original];
467-
468-
if (size >= 960 && midPreset != null) {
469-
computedParams = [lowPreset, midPreset, original];
470-
} else if (size >= 480) {
471-
computedParams = [lowPreset, original];
472-
}
519+
final computedParams = computeSimulcastPresets(
520+
dimensions: dimensions,
521+
original: original,
522+
requestedPresets: userParams,
523+
isScreenShare: isScreenShare,
524+
);
473525

474526
return encodingsFromPresets(
475527
dimensions,

test/utils_test.dart

Lines changed: 174 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import 'package:flutter_test/flutter_test.dart';
1616
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
1717

18-
import 'package:livekit_client/livekit_client.dart' as lk;
18+
import 'package:livekit_client/livekit_client.dart';
1919
import 'package:livekit_client/src/utils.dart';
2020

2121
void main() {
@@ -57,22 +57,187 @@ void main() {
5757
);
5858
});
5959

60+
group('simulcast encodings', () {
61+
test('same-resolution lower layer is clamped to top encoding', () {
62+
final presets = Utils.computeSimulcastPresets(
63+
dimensions: const VideoDimensions(1280, 720),
64+
original: const VideoParameters(
65+
dimensions: VideoDimensions(1280, 720),
66+
encoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
67+
),
68+
requestedPresets: const [
69+
VideoParametersPresets.h720_169,
70+
VideoParametersPresets.h360_169,
71+
],
72+
isScreenShare: false,
73+
);
74+
75+
expect(presets, hasLength(3));
76+
77+
expect(presets[0], VideoParametersPresets.h360_169);
78+
79+
expect(presets[1].dimensions, VideoDimensionsPresets.h720_169);
80+
expect(presets[1].encoding?.maxFramerate, 24);
81+
expect(presets[1].encoding?.maxBitrate, 1500000);
82+
83+
expect(presets[2].dimensions, const VideoDimensions(1280, 720));
84+
expect(presets[2].encoding?.maxFramerate, 24);
85+
expect(presets[2].encoding?.maxBitrate, 1500000);
86+
});
87+
88+
test('lower-resolution layer clamps framerate but preserves preset bitrate', () {
89+
final presets = Utils.computeSimulcastPresets(
90+
dimensions: const VideoDimensions(1280, 720),
91+
original: const VideoParameters(
92+
dimensions: VideoDimensions(1280, 720),
93+
encoding: VideoEncoding(maxBitrate: 500000, maxFramerate: 15),
94+
),
95+
requestedPresets: const [],
96+
isScreenShare: false,
97+
);
98+
99+
expect(presets, hasLength(3));
100+
expect(presets[1].dimensions, VideoDimensionsPresets.h360_169);
101+
expect(presets[1].encoding?.maxFramerate, 15);
102+
expect(presets[1].encoding?.maxBitrate, 450000);
103+
});
104+
105+
test('same-resolution full clamp', () {
106+
final presets = Utils.computeSimulcastPresets(
107+
dimensions: const VideoDimensions(854, 480),
108+
original: const VideoParameters(
109+
dimensions: VideoDimensions(854, 480),
110+
encoding: VideoEncoding(maxBitrate: 600000, maxFramerate: 15),
111+
),
112+
requestedPresets: const [
113+
VideoParameters(
114+
dimensions: VideoDimensions(854, 480),
115+
encoding: VideoEncoding(maxBitrate: 2000000, maxFramerate: 30),
116+
),
117+
],
118+
isScreenShare: false,
119+
);
120+
121+
expect(presets, hasLength(2));
122+
expect(presets[0].encoding?.maxFramerate, 15);
123+
expect(presets[0].encoding?.maxBitrate, 600000);
124+
expect(presets[1].dimensions, const VideoDimensions(854, 480));
125+
expect(presets[1].encoding?.maxFramerate, 15);
126+
expect(presets[1].encoding?.maxBitrate, 600000);
127+
});
128+
129+
test('ladder length follows the larger output dimension', () {
130+
const cases = [
131+
(VideoDimensions(320, 240), 1),
132+
(VideoDimensions(640, 480), 2),
133+
(VideoDimensions(1280, 720), 3),
134+
];
135+
136+
for (final (dimensions, expectedCount) in cases) {
137+
final presets = Utils.computeSimulcastPresets(
138+
dimensions: dimensions,
139+
original: VideoParameters(
140+
dimensions: dimensions,
141+
encoding: const VideoEncoding(maxBitrate: 1000000, maxFramerate: 30),
142+
),
143+
requestedPresets: const [],
144+
isScreenShare: false,
145+
);
146+
147+
expect(presets, hasLength(expectedCount), reason: 'dimensions=$dimensions');
148+
}
149+
});
150+
151+
test("presets that don't overshoot are passed through unchanged", () {
152+
const original = VideoParameters(
153+
dimensions: VideoDimensions(1920, 1080),
154+
encoding: VideoEncoding(maxBitrate: 5000000, maxFramerate: 30),
155+
);
156+
157+
final presets = Utils.computeSimulcastPresets(
158+
dimensions: const VideoDimensions(1920, 1080),
159+
original: original,
160+
requestedPresets: const [
161+
VideoParametersPresets.h360_169,
162+
VideoParametersPresets.h720_169,
163+
],
164+
isScreenShare: false,
165+
);
166+
167+
expect(presets, hasLength(3));
168+
expect(presets[0], VideoParametersPresets.h360_169);
169+
expect(presets[1], VideoParametersPresets.h720_169);
170+
expect(presets[2], original);
171+
});
172+
173+
test('clamped layer carries forward per-layer priorities', () {
174+
const prioritized = VideoParameters(
175+
dimensions: VideoDimensions(1280, 720),
176+
encoding: VideoEncoding(
177+
maxBitrate: 1700000,
178+
maxFramerate: 30,
179+
bitratePriority: Priority.high,
180+
networkPriority: Priority.high,
181+
),
182+
);
183+
184+
final presets = Utils.computeSimulcastPresets(
185+
dimensions: const VideoDimensions(1280, 720),
186+
original: const VideoParameters(
187+
dimensions: VideoDimensions(1280, 720),
188+
encoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
189+
),
190+
requestedPresets: const [
191+
VideoParametersPresets.h360_169,
192+
prioritized,
193+
],
194+
isScreenShare: false,
195+
);
196+
197+
expect(presets, hasLength(3));
198+
expect(presets[1].encoding?.maxFramerate, 24);
199+
expect(presets[1].encoding?.maxBitrate, 1500000);
200+
expect(presets[1].encoding?.bitratePriority, Priority.high);
201+
expect(presets[1].encoding?.networkPriority, Priority.high);
202+
});
203+
204+
test('computed encodings use clamped presets', () {
205+
final encodings = Utils.computeVideoEncodings(
206+
isScreenShare: false,
207+
dimensions: const VideoDimensions(1280, 720),
208+
options: const VideoPublishOptions(
209+
videoEncoding: VideoEncoding(maxBitrate: 1500000, maxFramerate: 24),
210+
videoSimulcastLayers: [
211+
VideoParametersPresets.h720_169,
212+
VideoParametersPresets.h360_169,
213+
],
214+
),
215+
);
216+
217+
expect(encodings, hasLength(3));
218+
expect(encodings![1].rid, 'h');
219+
expect(encodings[1].maxFramerate, 24);
220+
expect(encodings[1].maxBitrate, 1500000);
221+
expect(encodings[1].scaleResolutionDownBy, 1);
222+
});
223+
});
224+
60225
group('screen share simulcast encodings', () {
61226
test('screen share preset bitrates match common SDK presets', () {
62-
expect(lk.VideoParametersPresets.screenShareH720FPS5.encoding?.maxBitrate, 800000);
63-
expect(lk.VideoParametersPresets.screenShareH1080FPS30.encoding?.maxBitrate, 5000000);
227+
expect(VideoParametersPresets.screenShareH720FPS5.encoding?.maxBitrate, 800000);
228+
expect(VideoParametersPresets.screenShareH1080FPS30.encoding?.maxBitrate, 5000000);
64229
});
65230

66231
test('default lower layer follows top layer fps and priorities', () {
67232
final encodings = Utils.computeVideoEncodings(
68233
isScreenShare: true,
69-
dimensions: const lk.VideoDimensions(1920, 1080),
70-
options: const lk.VideoPublishOptions(
71-
screenShareEncoding: lk.VideoEncoding(
234+
dimensions: const VideoDimensions(1920, 1080),
235+
options: const VideoPublishOptions(
236+
screenShareEncoding: VideoEncoding(
72237
maxBitrate: 2500000,
73238
maxFramerate: 15,
74-
bitratePriority: lk.Priority.high,
75-
networkPriority: lk.Priority.high,
239+
bitratePriority: Priority.high,
240+
networkPriority: Priority.high,
76241
),
77242
),
78243
);
@@ -91,7 +256,7 @@ void main() {
91256
test('default lower layer follows selected screen share preset', () {
92257
final encodings = Utils.computeVideoEncodings(
93258
isScreenShare: true,
94-
dimensions: const lk.VideoDimensions(1920, 1080),
259+
dimensions: const VideoDimensions(1920, 1080),
95260
);
96261

97262
expect(encodings, hasLength(2));

0 commit comments

Comments
 (0)