feat(step-down): the bitrate floor is the tier's YouTube minimum

Step-down used a flat 3 Mbps (4 for vertical) as its floor — a number that had
nothing to do with the tier being streamed. With CBR holding the target, that
floor is a real picture-quality floor, so it has to follow YouTube's published
H.264 table (support.google.com/youtube/answer/2853702): 6 at 1080p60, 5 at
1080p30, 3 at 720p.

Models/RateLadder is that table as pure policy. Vertical is not a separate row:
1080x1920 and 1920x1080 are the same pixel count, so the frame rate alone picks
the band. StepDownPolicy takes the floor as an optional constructor argument and
refuses one above the starting rate, so an unusual start cannot begin under its
own floor. Omitted, the historical 3/4 floor still applies, which keeps the
existing policy tests meaningful and lets a caller opt in.

One consequence worth knowing: the three-step budget can stop above the floor
(12 → 9.6 → 7.7 → 6.2 from a 20% loss), so the floor is a limit, not a target.

669 pass, 0 warnings.
This commit is contained in:
2026-11-12 08:05:55 -08:00
parent 1708667602
commit 93719a286d
4 changed files with 60 additions and 3 deletions
+31
View File
@@ -0,0 +1,31 @@
namespace ytLive.Models;
/// <summary>
/// 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.
///
/// <para><b>Source:</b> 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 (<c>FfmpegArgs.AddRateControl</c>).</para>
///
/// <para><b>Vertical is not a separate row.</b> 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.</para>
/// </summary>
public static class RateLadder
{
/// <summary>Minimum and recommended rates for a tier.</summary>
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);
}
+7 -2
View File
@@ -105,8 +105,11 @@ public sealed class StepDownPolicy
/// <summary>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).</summary>
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
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;
+2 -1
View File
@@ -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;
}
+20
View File
@@ -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()
{