diff --git a/Models/RateLadder.cs b/Models/RateLadder.cs new file mode 100644 index 0000000..cc6cdab --- /dev/null +++ b/Models/RateLadder.cs @@ -0,0 +1,31 @@ +namespace ytLive.Models; + +/// +/// YouTube's published H.264 ingestion numbers for a tier, in Mbps — the floor a step-down may walk +/// to and the recommended rate a creator aims at. +/// +/// Source: support.google.com/youtube/answer/2853702 (checked 2026-11-12): 1080p@60 +/// 6 min / 17 recommended, 1080p@30 5/14, 720p@30 and @60 3/8. The same page specifies CBR, which is +/// why these numbers are a target the encoder actually holds (FfmpegArgs.AddRateControl). +/// +/// Vertical is not a separate row. 1080×1920 has the same pixel count as 1920×1080, so a +/// portrait tier takes the band of its landscape pair — the frame rate alone decides which row. +/// +public static class RateLadder +{ + /// Minimum and recommended rates for a tier. + public readonly record struct Band(double Minimum, double Recommended); + + public static Band For(int width, int height, int fps) + { + var is1080 = Math.Max(width, height) >= 1920; + return (is1080, fps > 30) switch + { + (true, true) => new Band(6.0, 17.0), + (true, false) => new Band(5.0, 14.0), + (_, _) => new Band(3.0, 8.0), + }; + } + + public static Band For(QualityOption tier) => For(tier.Width, tier.Height, tier.Fps); +} diff --git a/Models/StepDownPolicy.cs b/Models/StepDownPolicy.cs index 5602dd5..927c1fb 100644 --- a/Models/StepDownPolicy.cs +++ b/Models/StepDownPolicy.cs @@ -105,8 +105,11 @@ public sealed class StepDownPolicy /// Explicit geometry: the tier the creator actually chose, which is what decides the /// step budget (a 1080 start gets three, a 720 start gets one). + /// The lowest rate a step-down may reach — the tier's YouTube + /// minimum (). Never below the starting rate, so an unusual start cannot + /// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies. public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps, - bool allowClimb = false) + bool allowClimb = false, double? floorBitrateMbps = null) { _vertical = startHeight > startWidth; _allowClimb = allowClimb; @@ -114,7 +117,9 @@ public sealed class StepDownPolicy _ceilingWidth = startWidth; _ceilingHeight = startHeight; _ceilingBitrateMbps = startBitrateMbps; - _floorBitrateMbps = _vertical ? 4.0 : 3.0; + _floorBitrateMbps = Math.Min( + floorBitrateMbps ?? (_vertical ? 4.0 : 3.0), + startBitrateMbps); Fps = startFps; Width = startWidth; diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs index 921c33a..c37a3a0 100644 --- a/ViewModels/MainViewModel.StepDown.cs +++ b/ViewModels/MainViewModel.StepDown.cs @@ -26,7 +26,8 @@ public partial class MainViewModel var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput); _encoderTierOverride = null; _stepDownPlanner = new StepDownPlanner( - new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate), + new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate, + floorBitrateMbps: RateLadder.For(quality).Minimum), _sessionStartedAtUtc ?? DateTime.UtcNow); _framePump.StatsReported += OnPumpStatsReported; } diff --git a/ytLive.Tests/StepDownPolicyTests.cs b/ytLive.Tests/StepDownPolicyTests.cs index bd3ec8b..3bea151 100644 --- a/ytLive.Tests/StepDownPolicyTests.cs +++ b/ytLive.Tests/StepDownPolicyTests.cs @@ -138,6 +138,26 @@ public sealed class StepDownPolicyTests Assert.Equal(1920, policy.Width); } + [Fact] + public void UploadStep_StopsAtTheTiersYoutubeMinimumWhenOneIsGiven() + { + // 1080p60: YouTube's floor is 6 Mbps, not the historical 3. From 8 the ladder reaches it in two + // steps (8 → 6.4 → 6.0) and must then hold rather than cut into the minimum. + var floor = RateLadder.For(1920, 1080, 60).Minimum; + Assert.Equal(6.0, floor); + + var policy = new StepDownPolicy(1920, 1080, 60, 8.0, floorBitrateMbps: floor); + var t = 0.0; + + for (var i = 0; i < 12; i++) + { + t += 40; + policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)); // 20% loss + } + + Assert.Equal(6.0, policy.BitrateMbps); + } + [Fact] public void BothKindsOfEvidenceInOneWindow_DoesNothing() {