diff --git a/Packages/StreamVideo/Runtime/Core/LowLevelClient/PublisherPeerConnection.cs b/Packages/StreamVideo/Runtime/Core/LowLevelClient/PublisherPeerConnection.cs index 2a41ff40..0821f9bc 100644 --- a/Packages/StreamVideo/Runtime/Core/LowLevelClient/PublisherPeerConnection.cs +++ b/Packages/StreamVideo/Runtime/Core/LowLevelClient/PublisherPeerConnection.cs @@ -16,6 +16,7 @@ using StreamVideo.Libs.Utils; using StreamVideo.v1.Sfu.Models; using StreamVideo.v1.Sfu.Signal; +using SfuEvents = StreamVideo.v1.Sfu.Events; using Unity.WebRTC; using UnityEngine; using TrackType = StreamVideo.v1.Sfu.Models.TrackType; @@ -171,6 +172,104 @@ public override Task RestartIce() return Negotiate(iceRestart: true); } + /// + /// Apply quality changes requested by the SFU. Enables/disables simulcast layers and updates + /// encoding parameters (bitrate, framerate, resolution scale) to match current subscriber demands. + /// + public void ChangePublishQuality(SfuEvents.VideoSender videoSenderSettings) + { + if (videoSenderSettings.TrackType != TrackType.Video) + { + Logs.WarningIfDebug( + $"[{PeerType}] ChangePublishQuality: Ignoring track type: {videoSenderSettings.TrackType}"); + return; + } + + if (VideoSender == null) + { + Logs.WarningIfDebug($"[{PeerType}] ChangePublishQuality: VideoSender null, skip"); + return; + } + + var parameters = VideoSender.GetParameters(); + if (parameters.encodings == null || parameters.encodings.Length == 0) + { + Logs.WarningIfDebug($"[{PeerType}] ChangePublishQuality: No encodings on sender"); + return; + } + + PublishQualityDebugLogger.LogSfuRequest(Logs, PeerType.ToString(), videoSenderSettings); + PublishQualityDebugLogger.LogStateBefore(Logs, PeerType.ToString(), parameters); + + var changed = false; + + foreach (var encoding in parameters.encodings) + { + var rid = string.IsNullOrEmpty(encoding.rid) ? "f" : encoding.rid; + + SfuEvents.VideoLayerSetting matchingLayer = null; + foreach (var layer in videoSenderSettings.Layers) + { + if (layer.Name == rid) + { + matchingLayer = layer; + break; + } + } + + var shouldBeActive = matchingLayer?.Active ?? false; + if (encoding.active != shouldBeActive) + { + encoding.active = shouldBeActive; + changed = true; + } + + if (matchingLayer == null || !matchingLayer.Active) + { + continue; + } + + if (matchingLayer.MaxBitrate > 0 && encoding.maxBitrate != (ulong)matchingLayer.MaxBitrate) + { + encoding.maxBitrate = (ulong)matchingLayer.MaxBitrate; + changed = true; + } + + if (matchingLayer.ScaleResolutionDownBy >= 1f) + { + var targetScale = (double)matchingLayer.ScaleResolutionDownBy; + if (encoding.scaleResolutionDownBy == null + || Math.Abs(encoding.scaleResolutionDownBy.Value - targetScale) > 0.001) + { + encoding.scaleResolutionDownBy = targetScale; + changed = true; + } + } + + if (matchingLayer.MaxFramerate > 0 && encoding.maxFramerate != matchingLayer.MaxFramerate) + { + encoding.maxFramerate = matchingLayer.MaxFramerate; + changed = true; + } + } + + if (!changed) + { + Logs.InfoIfDebug($"[{PeerType}] ChangePublishQuality: No changes needed"); + return; + } + + var error = VideoSender.SetParameters(parameters); + if (error.errorType != RTCErrorType.None) + { + Logs.Error($"[{PeerType}] ChangePublishQuality: SetParameters FAILED: {error.errorType}"); + } + else + { + PublishQualityDebugLogger.LogStateAfter(Logs, PeerType.ToString(), parameters); + } + } + private async Task Negotiate(bool iceRestart = false) { if (_isNegotiating) diff --git a/Packages/StreamVideo/Runtime/Core/LowLevelClient/RtcSession.cs b/Packages/StreamVideo/Runtime/Core/LowLevelClient/RtcSession.cs index 145c89db..0172e2b1 100644 --- a/Packages/StreamVideo/Runtime/Core/LowLevelClient/RtcSession.cs +++ b/Packages/StreamVideo/Runtime/Core/LowLevelClient/RtcSession.cs @@ -1946,7 +1946,17 @@ private void OnSfuCallGrantsUpdated(CallGrantsUpdated callGrantsUpdated) private void OnSfuChangePublishQuality(ChangePublishQuality changePublishQuality) { _sfuTracer?.Trace(PeerConnectionTraceKey.ChangePublishQuality, changePublishQuality); - // StreamTODO: Implement OnSfuChangePublishQuality + + if (Publisher == null) + { + _logs.WarningIfDebug("Received ChangePublishQuality but Publisher is null"); + return; + } + + foreach (var videoSender in changePublishQuality.VideoSenders) + { + Publisher.ChangePublishQuality(videoSender); + } } private void OnSfuConnectionQualityChanged(ConnectionQualityChanged connectionQualityChanged) diff --git a/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs b/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs new file mode 100644 index 00000000..51a93bb4 --- /dev/null +++ b/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs @@ -0,0 +1,108 @@ +using System.Text; +using StreamVideo.Libs.Logs; +using Unity.WebRTC; +using SfuEvents = StreamVideo.v1.Sfu.Events; + +namespace StreamVideo.Core.Utils +{ + /// + /// Compact debug logging for ChangePublishQuality events. + /// Log format per layer: "rid=ON/OFF br=300k fps=15 sc=4" + /// Abbreviations: br=bitrate, fps=framerate, sc=scaleResolutionDownBy + /// + internal static class PublishQualityDebugLogger + { + public static void LogSfuRequest(ILogs logs, string peerType, + SfuEvents.VideoSender videoSenderSettings) + { + var sb = new StringBuilder(); + sb.Append($"[{peerType}] ChangePublishQuality SFU req:"); + + foreach (var layer in videoSenderSettings.Layers) + { + sb.Append($" {layer.Name}="); + if (!layer.Active) + { + sb.Append("OFF |"); + continue; + } + + sb.Append("ON br="); + sb.Append(FormatBitrate(layer.MaxBitrate)); + sb.Append(" fps="); + sb.Append(layer.MaxFramerate); + sb.Append(" sc="); + sb.Append(layer.ScaleResolutionDownBy); + sb.Append(" |"); + } + + TrimTrailingSeparator(sb); + logs.Warning(sb.ToString()); + } + + public static void LogStateBefore(ILogs logs, string peerType, + RTCRtpSendParameters parameters) + { + logs.Warning($"[{peerType}] ChangePublishQuality before: {FormatEncodings(parameters)}"); + } + + public static void LogStateAfter(ILogs logs, string peerType, + RTCRtpSendParameters parameters) + { + logs.Warning($"[{peerType}] ChangePublishQuality after: {FormatEncodings(parameters)}"); + } + + private static string FormatEncodings(RTCRtpSendParameters parameters) + { + var sb = new StringBuilder(); + foreach (var encoding in parameters.encodings) + { + var rid = string.IsNullOrEmpty(encoding.rid) ? "f" : encoding.rid; + sb.Append($"{rid}="); + if (!encoding.active) + { + sb.Append("OFF | "); + continue; + } + + sb.Append("ON br="); + sb.Append(FormatBitrate(encoding.maxBitrate)); + sb.Append(" fps="); + sb.Append(encoding.maxFramerate?.ToString() ?? "?"); + sb.Append(" sc="); + sb.Append(encoding.scaleResolutionDownBy?.ToString("F0") ?? "?"); + sb.Append(" | "); + } + + if (sb.Length >= 3) + { + sb.Length -= 3; + } + + return sb.ToString(); + } + + private static string FormatBitrate(ulong? bps) + { + if (!bps.HasValue) return "?"; + return bps.Value >= 1_000_000 + ? $"{bps.Value / 1_000_000d:F1}m" + : $"{bps.Value / 1_000d:F0}k"; + } + + private static string FormatBitrate(int bps) + { + return bps >= 1_000_000 + ? $"{bps / 1_000_000d:F1}m" + : $"{bps / 1_000d:F0}k"; + } + + private static void TrimTrailingSeparator(StringBuilder sb) + { + if (sb.Length >= 2 && sb[sb.Length - 1] == '|') + { + sb.Length -= 2; + } + } + } +} diff --git a/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs.meta b/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs.meta new file mode 100644 index 00000000..6fcd66e1 --- /dev/null +++ b/Packages/StreamVideo/Runtime/Core/Utils/PublishQualityDebugLogger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba1bd0ab7df0bee4ea7824cad181a62b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: