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: