Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/video-start-bitrate-connection-level
Original file line number Diff line number Diff line change
@@ -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"
179 changes: 157 additions & 22 deletions lib/src/core/transport.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<String, dynamic> 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
Comment thread
xianshijing-lk marked this conversation as resolved.
/// 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<dynamic> media, List<TrackBitrateInfo> 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;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
}
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<String, dynamic> 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(<String, dynamic>{
'payload': codecPayload,
'config': 'x-google-start-bitrate=$startBitrate',
});
media['fmtp'] = fmtpList;
}

typedef TransportOnOffer = void Function(rtc.RTCSessionDescription offer);
typedef PeerConnectionCreate =
Future<rtc.RTCPeerConnection> Function(Map<String, dynamic> configuration, [Map<String, dynamic> constraints]);
Expand All @@ -63,6 +180,14 @@ class Transport extends Disposable {
final rtc.RTCPeerConnection pc;
final List<rtc.RTCIceCandidate> _pendingCandidates = [];
final List<TrackBitrateInfo> _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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -267,7 +393,16 @@ class Transport extends Disposable {
return null;
}

@visibleForTesting
List<TrackBitrateInfo> 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);
}

Expand Down
44 changes: 27 additions & 17 deletions lib/src/participant/local.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<LocalTrackPublication> {
Expand Down Expand Up @@ -403,14 +405,18 @@ class LocalParticipant extends Participant<LocalTrackPublication> {
//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();
Expand Down Expand Up @@ -505,14 +511,18 @@ class LocalParticipant extends Participant<LocalTrackPublication> {
//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();
Expand Down
24 changes: 24 additions & 0 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<rtc.RTCRtpEncoding>? encodings) {
if (encodings == null || encodings.isEmpty) {
return 0;
}
if (isSVCCodec(codec) && !isSVCSimulcast(codec, options)) {
return encodings.first.maxBitrate ?? 0;
}
return encodings.fold<int>(0, (sum, e) => sum + (e.maxBitrate ?? 0));
}

class ScalabilityMode {
late num spatial;

Expand Down
Loading
Loading