diff --git a/.changes/video-start-bitrate-connection-level b/.changes/video-start-bitrate-connection-level new file mode 100644 index 000000000..d8e18a1cf --- /dev/null +++ b/.changes/video-start-bitrate-connection-level @@ -0,0 +1 @@ +patch type="fixed" "The video start bitrate hint is now one connection-level value written once per publisher connection, capped at 1 Mbps for camera tracks (screen share stays uncapped so its content is legible immediately) and skipped below a 300 kbps target. libwebrtc applies this hint to the whole peer connection, so a per-track value rewritten on every offer resolved to last-writer-wins on m-section order, and an unpublished track could still seed the connection through the section it left behind. The target is also taken from the sum of the simulcast encodings rather than the lowest layer alone" \ No newline at end of file diff --git a/lib/src/core/transport.dart b/lib/src/core/transport.dart index 79a581efe..a6a605900 100644 --- a/lib/src/core/transport.dart +++ b/lib/src/core/transport.dart @@ -13,8 +13,10 @@ // limitations under the License. import 'dart:async'; +import 'dart:math' as math; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:meta/meta.dart'; import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; import '../exceptions.dart'; @@ -38,22 +40,137 @@ const ddExtensionURI = 'https://aomediacodec.github.io/av1-rtp-spec/#dependency- * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target. * Why same for all codecs: Target bitrate already accounts for codec efficiency * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient). + * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks. */ const startBitrateMultiplier = 0.9; +/// Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. +const maxStartBitrateKbps = 1000; + +/// Minimum target bitrate in kbps for the start bitrate hint. Below this, seeding above the +/// real capacity costs more than the ramp it saves, so libwebrtc's default is left in place. +const minTargetBitrateKbps = 300; + class TrackBitrateInfo { String? cid; rtc.RTCRtpTransceiver? transceiver; String codec; int maxbr; + bool isScreenShare; TrackBitrateInfo({ required this.cid, required this.transceiver, required this.codec, required this.maxbr, + this.isScreenShare = false, }); } +/// Codec payload for [codec] in this media section, when the section carries [cid]. +/// +/// Returns `null` when the section does not belong to the track, `0` when it does but does +/// not offer the requested codec, and the codec payload otherwise. +@internal +int? findTrackCodecPayload(Map media, String cid, String codec) { + final msid = media['msid']; + if (msid is! String || !msid.contains(cid)) { + return null; + } + for (final rtp in (media['rtp'] as List? ?? const [])) { + if ((rtp['codec'] as String?)?.toUpperCase() == codec.toUpperCase()) { + return rtp['payload'] as int; + } + } + return 0; +} + +/// Start bitrate hinted for a single track, or `null` when its target is too low to be +/// worth seeding. +/// +/// 90% of the target leaves ~10% headroom for the estimator to settle. The same multiplier +/// is used for every codec because the target already reflects the codec's efficiency. +/// Camera is capped at 1 Mbps so the estimator does not open too aggressively on a +/// high-bitrate track; screen share is exempt, because its content needs the bitrate +/// immediately to stay legible. +@internal +int? computeTrackStartBitrate(TrackBitrateInfo trackbr) { + if (trackbr.maxbr < minTargetBitrateKbps) { + return null; + } + final calculated = (trackbr.maxbr * startBitrateMultiplier).round(); + return trackbr.isScreenShare ? calculated : math.min(calculated, maxStartBitrateKbps); +} + +/// The single start bitrate for this peer connection: the largest hint among the video +/// m-sections of [media] that map to a published track. +/// +/// libwebrtc reads `x-google-start-bitrate` per m-section but applies it to the shared +/// `Call` (`WebRtcVideoSendChannel::ApplyChangedParams` -> `SetSdpBitrateParameters`), where +/// `RtpBitrateConfigurator` holds one config for the whole connection. Differing per-section +/// values are therefore last-writer-wins, decided by m-section order, so every video section +/// gets the same number instead. +/// +/// Only sections that can send local media are considered. [trackBitrates] is append-only and +/// an unpublished section keeps its `a=msid`, so matching on msid alone would still pair a +/// stale entry with its old section — letting an uncapped screen-share target seed a +/// connection that now carries only a camera, or consuming the one-shot hint on a section that +/// sends nothing. `recvonly` and `inactive` are the only directions that cannot carry local +/// media, and they are exactly where a section lands once its sender is removed (`removeTrack` +/// moves sendonly to inactive and sendrecv to recvonly); everything else sends, including a +/// section with no direction attribute, which SDP defaults to `sendrecv`. +@internal +int? computeConnectionStartBitrate(List media, List trackBitrates) { + int? connectionStartBitrate; + for (final m in media) { + final direction = m['direction']; + if (m['type'] != 'video' || direction == 'recvonly' || direction == 'inactive') { + continue; + } + for (final trackbr in trackBitrates) { + final cid = trackbr.cid; + if (cid == null) { + continue; + } + final codecPayload = findTrackCodecPayload(m, cid, trackbr.codec); + if (codecPayload == null) { + continue; + } + final startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr) : null; + if (startBitrate != null && (connectionStartBitrate == null || startBitrate > connectionStartBitrate)) { + connectionStartBitrate = startBitrate; + } + break; + } + } + return connectionStartBitrate; +} + +/// Declares `x-google-start-bitrate` on [codecPayload]'s fmtp. This SDP munging is used for a +/// bitrate setting that cannot be applied through the sender's encodings. +/// +/// The section always carries the hint afterwards: either its existing fmtp line gains the +/// parameter, or one is created for the payload. +@internal +void applyVideoStartBitrate(Map media, int codecPayload, int startBitrate) { + final fmtpList = (media['fmtp'] as List? ?? const []); + for (final fmtp in fmtpList) { + if (fmtp['payload'] == codecPayload) { + // A payload type is shared across the bundle, so a value written for one section is + // already the connection-level one; leave it rather than rewrite it. + if (!(fmtp['config'] as String).contains('x-google-start-bitrate')) { + fmtp['config'] += ';x-google-start-bitrate=$startBitrate'; + } + return; + } + } + // VP8 and some codecs may not have an existing fmtp line. + fmtpList.add({ + 'payload': codecPayload, + 'config': 'x-google-start-bitrate=$startBitrate', + }); + media['fmtp'] = fmtpList; +} + typedef TransportOnOffer = void Function(rtc.RTCSessionDescription offer); typedef PeerConnectionCreate = Future Function(Map configuration, [Map constraints]); @@ -63,6 +180,14 @@ class Transport extends Disposable { final rtc.RTCPeerConnection pc; final List _pendingCandidates = []; final List _bitrateTrackers = []; + + /// Whether an offer carrying the connection-level `x-google-start-bitrate` has been + /// accepted locally. The hint is written once per peer connection: libwebrtc retains + /// `start_bitrate_bps` in `RtpBitrateConfigurator` and re-applies it on network route + /// changes, so a later rewrite is at best a no-op and at worst restarts a converged + /// bandwidth estimator. A new peer connection (full reconnect) seeds a new estimator. + bool _hasAppliedVideoStartBitrate = false; + bool restartingIce = false; bool renegotiate = false; TransportOnOffer? onOffer; @@ -189,47 +314,48 @@ class Transport extends Disposable { } final sdpParsed = sdp_transform.parse(offer.sdp ?? ''); + // One value for every video m-section, written only on the first offer that carries local + // video: the hint is connection-level in libwebrtc, so differing per-section values would + // be last-writer-wins on m-section order. Offers before any video is published (data + // channel or audio only) find no target and leave the latch unset. + final connectionStartBitrate = _hasAppliedVideoStartBitrate + ? null + : computeConnectionStartBitrate(sdpParsed['media'] ?? const [], _bitrateTrackers); + var appliedVideoStartBitrate = false; sdpParsed['media']?.forEach((media) { if (media['type'] == 'video') { ensureVideoDDExtensionForSVC(media, media['type'], media['port'], media['protocol'], media['payloads']); // mung sdp for codec bitrate setting that can't apply by sendEncoding for (var trackbr in _bitrateTrackers) { - if (media['msid'] == null || trackbr.cid == null || !(media['msid'] as String).contains(trackbr.cid!)) { + final cid = trackbr.cid; + if (cid == null) { continue; } - - var codecPayload = 0; - for (var rtp in media['rtp']) { - if (rtp['codec']?.toUpperCase() == trackbr.codec.toUpperCase()) { - codecPayload = rtp['payload']; - continue; - } + final codecPayload = findTrackCodecPayload(media, cid, trackbr.codec); + if (codecPayload == null) { continue; } - - if (codecPayload == 0) { - continue; + if (codecPayload > 0 && connectionStartBitrate != null) { + applyVideoStartBitrate(media, codecPayload, connectionStartBitrate); + appliedVideoStartBitrate = true; } - - for (var fmtp in media['fmtp']) { - if (fmtp['payload'] == codecPayload) { - if (!(fmtp['config'] as String).contains('x-google-start-bitrate')) { - fmtp['config'] += ';x-google-start-bitrate=${(trackbr.maxbr * startBitrateMultiplier).toInt()}'; - } - break; - } - } - continue; + break; } } }); + final mungedSdp = sdp_transform.write(sdpParsed, null); try { - await setMungedSDP(sd: offer, munged: sdp_transform.write(sdpParsed, null)); + await setMungedSDP(sd: offer, munged: mungedSdp); } catch (e) { throw NegotiationError(e.toString()); } + // setMungedSDP falls back to the unmunged SDP on rejection. Only consume the one-shot + // hint once the SDP carrying it has been accepted locally. + if (appliedVideoStartBitrate && offer.sdp == mungedSdp) { + _hasAppliedVideoStartBitrate = true; + } onOffer?.call(offer); } @@ -267,7 +393,16 @@ class Transport extends Disposable { return null; } + @visibleForTesting + List get bitrateTrackers => List.unmodifiable(_bitrateTrackers); + void setTrackBitrateInfo(TrackBitrateInfo info) { + // One entry per cid. A LocalTrack keeps its cid across unpublish and republish, and + // `computeConnectionStartBitrate` stops at the first entry whose cid the section carries, + // so a leftover entry would shadow the republished track's new target — and with a stale + // sub-floor target it would suppress the hint entirely. Replacing also keeps the list from + // growing for the lifetime of the connection. + _bitrateTrackers.removeWhere((e) => e.cid != null && e.cid == info.cid); _bitrateTrackers.add(info); } diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index becdb8939..61cd38434 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -56,9 +56,11 @@ import '../types/data_stream.dart'; import '../types/other.dart'; import '../types/participant_permissions.dart'; import '../types/video_dimensions.dart'; -import '../utils.dart' show buildStreamId, mimeTypeToVideoCodecString, Utils, isSVCCodec, isVideoCodec; import 'participant.dart'; +import '../utils.dart' + show buildStreamId, computeStartTargetBitrate, mimeTypeToVideoCodecString, Utils, isSVCCodec, isVideoCodec; + /// Represents the current participant in the room. Instance of [LocalParticipant] is automatically /// created after successfully connecting to a [Room] and will be accessible from [Room.localParticipant]. class LocalParticipant extends Participant { @@ -403,14 +405,18 @@ class LocalParticipant extends Participant { //TOOD: } else if (isVideoCodec(options.videoCodec) && encodings?.first.maxBitrate != null) { // Apply start bitrate for all video codecs to prevent initial blurriness - room.engine.publisher?.setTrackBitrateInfo( - TrackBitrateInfo( - cid: track.getCid(), - transceiver: track.transceiver, - codec: options.videoCodec, - maxbr: encodings![0].maxBitrate! ~/ 1000, - ), - ); + final targetBitrate = computeStartTargetBitrate(options.videoCodec, options, encodings); + if (targetBitrate > 0) { + room.engine.publisher?.setTrackBitrateInfo( + TrackBitrateInfo( + cid: track.getCid(), + transceiver: track.transceiver, + codec: options.videoCodec, + maxbr: targetBitrate ~/ 1000, + isScreenShare: track.source == TrackSource.screenShareVideo, + ), + ); + } } await room.engine.negotiate(); @@ -505,14 +511,18 @@ class LocalParticipant extends Participant { //TOOD: } else if (isVideoCodec(publishOptions.videoCodec) && encodings?.first.maxBitrate != null) { // Apply start bitrate for all video codecs to prevent initial blurriness - room.engine.publisher?.setTrackBitrateInfo( - TrackBitrateInfo( - cid: track.getCid(), - transceiver: track.transceiver, - codec: publishOptions.videoCodec, - maxbr: encodings![0].maxBitrate! ~/ 1000, - ), - ); + final targetBitrate = computeStartTargetBitrate(publishOptions.videoCodec, publishOptions, encodings); + if (targetBitrate > 0) { + room.engine.publisher?.setTrackBitrateInfo( + TrackBitrateInfo( + cid: track.getCid(), + transceiver: track.transceiver, + codec: publishOptions.videoCodec, + maxbr: targetBitrate ~/ 1000, + isScreenShare: track.source == TrackSource.screenShareVideo, + ), + ); + } } await room.engine.negotiate(); diff --git a/lib/src/utils.dart b/lib/src/utils.dart index c29d3ca23..88a56e5f8 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -644,6 +644,30 @@ bool isVideoCodec(String codec) => ['vp8', 'vp9', 'av1', 'h264', 'h265'].contain bool isAV1Codec(String codec) => codec.toLowerCase() == 'av1'; +/// Whether [codec] is being published as SVC-flavoured simulcast rather than as a single +/// SVC stream: an `L1T*` scalability mode with simulcast on means the encodings are +/// independent streams, not the spatial layers of one. +bool isSVCSimulcast(String codec, VideoPublishOptions? options) => + isSVCCodec(codec) && (options?.simulcast ?? false) && (options?.scalabilityMode?.startsWith('L1T') ?? false); + +/// The publish target the `x-google-start-bitrate` hint is derived from, in bps. +/// +/// A single SVC stream declares its whole budget on the first encoding, so that one value is +/// the target. Everything else — plain simulcast, and SVC published as simulcast — spreads +/// the budget across independent encodings, so the target is their sum. Taking only the first +/// encoding there would read the lowest simulcast layer (rids are ordered `q`, `h`, `f`) and +/// understate the target by roughly an order of magnitude. +@internal +int computeStartTargetBitrate(String codec, VideoPublishOptions? options, List? encodings) { + if (encodings == null || encodings.isEmpty) { + return 0; + } + if (isSVCCodec(codec) && !isSVCSimulcast(codec, options)) { + return encodings.first.maxBitrate ?? 0; + } + return encodings.fold(0, (sum, e) => sum + (e.maxBitrate ?? 0)); +} + class ScalabilityMode { late num spatial; diff --git a/test/core/start_bitrate_test.dart b/test/core/start_bitrate_test.dart new file mode 100644 index 000000000..a30a682a0 --- /dev/null +++ b/test/core/start_bitrate_test.dart @@ -0,0 +1,276 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@Timeout(Duration(seconds: 5)) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; +import 'package:sdp_transform/sdp_transform.dart' as sdp_transform; + +import 'package:livekit_client/src/options.dart' show ConnectOptions, VideoPublishOptions; +import 'package:livekit_client/src/utils.dart' show computeStartTargetBitrate; +import '../mock/peerconnection_mock.dart'; + +import 'package:livekit_client/src/core/transport.dart' + show + TrackBitrateInfo, + Transport, + applyVideoStartBitrate, + computeConnectionStartBitrate, + computeTrackStartBitrate, + findTrackCodecPayload; + +/// Parse the `key[=value]` pairs of an fmtp config into a comparable set. +Set paramSet(String config) => config.split(';').where((e) => e.isNotEmpty).toSet(); + +String? fmtpOf(List media, String mid, int payload) { + final m = media.firstWhere((section) => '${section['mid']}' == mid); + for (final fmtp in (m['fmtp'] as List? ?? const [])) { + if (fmtp['payload'] == payload) return fmtp['config'] as String; + } + return null; +} + +// Two published video sections: a screen share on mid 0 and a camera on mid 1. +const twoVideoSections = '''v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:0 +a=sendonly +a=msid:stream other-track +a=rtpmap:96 VP8/90000 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:1 +a=sendonly +a=msid:stream camera-cid +a=rtpmap:96 VP8/90000'''; + +// The same bundle after the screen share is unpublished. `removeTrack` moves the sendonly +// transceiver to inactive, but the section keeps its `a=msid`, so it still matches the +// append-only bitrate tracker. Only the camera is still sending. +const unpublishedScreenShare = '''v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:0 +a=inactive +a=msid:stream other-track +a=rtpmap:96 VP8/90000 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:1 +a=sendonly +a=msid:stream camera-cid +a=rtpmap:96 VP8/90000'''; + +// A sendrecv section and one with no direction attribute at all, which SDP defaults to +// sendrecv. Both can carry local media. +const sendrecvSections = '''v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=- +t=0 0 +a=group:BUNDLE 0 1 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:0 +a=sendrecv +a=msid:stream camera-cid +a=rtpmap:96 VP8/90000 +m=video 9 UDP/TLS/RTP/SAVPF 96 +c=IN IP4 0.0.0.0 +a=mid:1 +a=msid:stream other-track +a=rtpmap:96 VP8/90000'''; + +List mediaOf(String sdp) => sdp_transform.parse(sdp)['media'] as List; + +void main() { + group('video start bitrate', () { + test('matches only the section whose msid track ID matches the cid', () { + final media = mediaOf(twoVideoSections); + + expect(findTrackCodecPayload(media[0], 'camera-cid', 'VP8'), isNull); + expect(findTrackCodecPayload(media[1], 'camera-cid', 'VP8'), 96); + // Section belongs to the track but does not offer the codec. + expect(findTrackCodecPayload(media[1], 'camera-cid', 'AV1'), 0); + }); + + test('applies the bitrate only to the section it is given', () { + final media = mediaOf(twoVideoSections); + + applyVideoStartBitrate(media[1], 96, 900); + + expect(fmtpOf(media, '0', 96), isNull); + expect(paramSet(fmtpOf(media, '1', 96)!), contains('x-google-start-bitrate=900')); + }); + + test('caps camera at 1 Mbps but leaves screen share uncapped', () { + final camera = TrackBitrateInfo(cid: 'c', transceiver: null, codec: 'VP8', maxbr: 3000); + final screenShare = TrackBitrateInfo( + cid: 'c', + transceiver: null, + codec: 'VP8', + maxbr: 3000, + isScreenShare: true, + ); + + expect(computeTrackStartBitrate(camera), 1000); + expect(computeTrackStartBitrate(screenShare), 2700); + }); + + test('gives no hint below the 300 kbps target floor', () { + expect( + computeTrackStartBitrate(TrackBitrateInfo(cid: 'c', transceiver: null, codec: 'VP8', maxbr: 299)), + isNull, + ); + expect( + computeTrackStartBitrate(TrackBitrateInfo(cid: 'c', transceiver: null, codec: 'VP8', maxbr: 300)), + 270, + ); + }); + + test('uses one connection-level value: the largest hint across video sections', () { + final startBitrate = computeConnectionStartBitrate(mediaOf(twoVideoSections), [ + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 1000), + TrackBitrateInfo(cid: 'other-track', transceiver: null, codec: 'VP8', maxbr: 3000, isScreenShare: true), + ]); + + expect(startBitrate, 2700); + }); + + test('ignores registered tracks with no section in the current SDP', () { + final startBitrate = computeConnectionStartBitrate(mediaOf(twoVideoSections), [ + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 1000), + // Stale entry: the tracker list is append-only and outlives an unpublish. + TrackBitrateInfo(cid: 'unpublished-cid', transceiver: null, codec: 'VP8', maxbr: 8000, isScreenShare: true), + ]); + + expect(startBitrate, 900); + }); + + test('ignores a section that stopped sending but kept its msid', () { + List trackers() => [ + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 1000), + TrackBitrateInfo(cid: 'other-track', transceiver: null, codec: 'VP8', maxbr: 8000, isScreenShare: true), + ]; + + // While both send, the uncapped screen share wins the connection-level max. + expect(computeConnectionStartBitrate(mediaOf(twoVideoSections), trackers()), 7200); + // Once unpublished its entry is stale, so only the capped camera counts. Both + // directions a removed sender can land on are excluded: `inactive` from a sendonly + // transceiver, `recvonly` from a sendrecv one. + expect(computeConnectionStartBitrate(mediaOf(unpublishedScreenShare), trackers()), 900); + expect( + computeConnectionStartBitrate( + mediaOf(unpublishedScreenShare.replaceAll('a=inactive', 'a=recvonly')), + trackers(), + ), + 900, + ); + }); + + test('counts sendrecv and direction-less sections, which still send local media', () { + final media = mediaOf(sendrecvSections); + + expect( + computeConnectionStartBitrate(media, [ + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 1000), + ]), + 900, + ); + expect( + computeConnectionStartBitrate(media, [ + TrackBitrateInfo(cid: 'other-track', transceiver: null, codec: 'VP8', maxbr: 2000, isScreenShare: true), + ]), + 1800, + ); + }); + + test('republishing a track replaces its tracker instead of shadowing it', () async { + final transport = await Transport.create(MockPeerConnection.create, connectOptions: const ConnectOptions()); + addTearDown(transport.dispose); + + // First publish below the floor: no hint, so the one-shot latch stays unset. + transport.setTrackBitrateInfo( + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 250), + ); + expect(computeConnectionStartBitrate(mediaOf(twoVideoSections), transport.bitrateTrackers), isNull); + + // A LocalTrack keeps its cid across unpublish and republish, and the lookup stops at the + // first entry whose cid the section carries — so a leftover entry would shadow this one. + transport.setTrackBitrateInfo( + TrackBitrateInfo(cid: 'camera-cid', transceiver: null, codec: 'VP8', maxbr: 1500), + ); + + expect(computeConnectionStartBitrate(mediaOf(twoVideoSections), transport.bitrateTrackers), 1000); + expect(transport.bitrateTrackers.length, 1); + }); + + test('gives no connection value when nothing sending matches', () { + expect(computeConnectionStartBitrate(mediaOf(twoVideoSections), []), isNull); + expect( + computeConnectionStartBitrate(mediaOf(unpublishedScreenShare), [ + TrackBitrateInfo(cid: 'other-track', transceiver: null, codec: 'VP8', maxbr: 8000, isScreenShare: true), + ]), + isNull, + ); + }); + }); + + group('computeStartTargetBitrate', () { + final simulcast = [ + rtc.RTCRtpEncoding(rid: 'q', maxBitrate: 150000), + rtc.RTCRtpEncoding(rid: 'h', maxBitrate: 500000), + rtc.RTCRtpEncoding(rid: 'f', maxBitrate: 1700000), + ]; + + test('sums simulcast encodings rather than reading the lowest layer', () { + expect(computeStartTargetBitrate('vp8', const VideoPublishOptions(), simulcast), 2350000); + }); + + test('uses the single encoding for an SVC stream', () { + expect( + computeStartTargetBitrate('vp9', const VideoPublishOptions(scalabilityMode: 'L3T3_KEY'), [ + rtc.RTCRtpEncoding(maxBitrate: 1700000), + ]), + 1700000, + ); + }); + + test('sums encodings for SVC published as simulcast (L1T*)', () { + expect( + computeStartTargetBitrate( + 'vp9', + const VideoPublishOptions(simulcast: true, scalabilityMode: 'L1T3'), + simulcast, + ), + 2350000, + ); + }); + + test('returns zero when there are no encodings', () { + expect(computeStartTargetBitrate('vp8', const VideoPublishOptions(), null), 0); + expect(computeStartTargetBitrate('vp8', const VideoPublishOptions(), []), 0); + }); + }); +}