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()
{