diff --git a/MainWindow.xaml b/MainWindow.xaml
index fdbedac..3b469b9 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -145,6 +145,20 @@
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding CanChangeQuality}"/>
+
+
+
+
+
+
+
+
+
diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs
index 4c43f8c..8899bcd 100644
--- a/Services/LayoutStore.Settings.cs
+++ b/Services/LayoutStore.Settings.cs
@@ -27,6 +27,26 @@ public partial class LayoutStore : IDisposable
public void SaveStickyAlertsUntilDismissed(bool enabled)
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
+ /// The creator's own video rate for one tier, in Mbps, or null to use the shipped default.
+ /// Per tier because each has its own ceiling; the rate is a target the encoder is asked to hold,
+ /// so a stored number means the same thing on every run (CBR).
+ public double? LoadRatedMbps(string tierDisplay)
+ => double.TryParse(GetSetting($"Video.Rate.{tierDisplay}"), out var mbps) && mbps > 0 ? mbps : null;
+
+ public void SaveRatedMbps(string tierDisplay, double? mbps)
+ {
+ if (mbps is not > 0)
+ {
+ using var delete = _connection.CreateCommand();
+ delete.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+ delete.Parameters.AddWithValue("$key", $"Video.Rate.{tierDisplay}");
+ delete.ExecuteNonQuery();
+ return;
+ }
+
+ UpsertSetting($"Video.Rate.{tierDisplay}", mbps.Value.ToString("0.#"));
+ }
+
private static bool IsSupportedNotificationPosition(NotificationPosition position)
=> position is NotificationPosition.TopLeft or NotificationPosition.TopRight
or NotificationPosition.CenterLeft or NotificationPosition.CenterRight
diff --git a/ViewModels/MainViewModel.Clips.cs b/ViewModels/MainViewModel.Clips.cs
index 28f08da..3ad18e1 100644
--- a/ViewModels/MainViewModel.Clips.cs
+++ b/ViewModels/MainViewModel.Clips.cs
@@ -99,7 +99,7 @@ public partial class MainViewModel
return;
}
- var quality = Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
+ var quality = EffectiveQuality(Models.OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
var bytes = ClipLibrary.RingBytesFor(_clipSeconds, (int)Math.Round(quality.Bitrate * 1000));
if (_clipRing is null || _clipRingCapacity != bytes)
{
diff --git a/ViewModels/MainViewModel.Rate.cs b/ViewModels/MainViewModel.Rate.cs
new file mode 100644
index 0000000..cd4b510
--- /dev/null
+++ b/ViewModels/MainViewModel.Rate.cs
@@ -0,0 +1,89 @@
+using ytLive.Models;
+
+namespace ytLive.ViewModels;
+
+///
+/// The creator's own video rate, per tier — the latitude the tier table deliberately does not take
+/// away. A creator who knows their upload can set any rate from the tier's YouTube minimum upward;
+/// nothing here invents a ceiling, because a wrong guess is what live step-down exists to catch.
+///
+/// Why per tier: a number that is sensible at 1080p60 is wasteful at 720p30, and the
+/// creator who tuned one tier has not tuned the other. Unset means "use the shipped default", which
+/// is what substitutes.
+///
+/// Why this matters now: with CBR holding the target (FfmpegArgs.AddRateControl)
+/// the number the creator types is the number that goes out, so it is a real quality choice rather
+/// than an average to undershoot.
+///
+public partial class MainViewModel
+{
+ private readonly Dictionary _ratedMbpsByTier = new(StringComparer.Ordinal);
+
+ /// The tier with the creator's rate substituted for the shipped default. Every consumer
+ /// that builds encoder options or a step-down policy must use this, never the raw tier.
+ public QualityOption EffectiveQuality(QualityOption tier)
+ => RatedFor(tier) is var rated && Math.Abs(rated - tier.Bitrate) > 0.001
+ ? tier with { Bitrate = rated }
+ : tier;
+
+ private double RatedFor(QualityOption tier)
+ {
+ if (_ratedMbpsByTier.TryGetValue(tier.Display, out var cached)) return cached;
+
+ // The constructor resolves the default tier before the layout store exists, so the first read
+ // must not cache: a cached default would shadow the creator's stored rate for that run.
+ var stored = _layoutStore is null ? null : _layoutStore.LoadRatedMbps(tier.Display);
+ var value = Math.Max(stored ?? tier.Bitrate, RateLadder.For(tier).Minimum);
+ if (_layoutStore is not null) _ratedMbpsByTier[tier.Display] = value;
+ return value;
+ }
+
+ /// The rate the selected tier will stream at, in Mbps.
+ public double RatedMbps
+ {
+ get => RatedFor(SelectedQuality);
+ set
+ {
+ var tier = SelectedQuality;
+ var band = RateLadder.For(tier);
+ // Below the tier minimum YouTube will not hold the resolution; above ~100 is not a stream.
+ var clamped = Math.Clamp(Math.Round(value, 1), band.Minimum, 100.0);
+ if (Math.Abs(clamped - RatedFor(tier)) < 0.05)
+ {
+ OnPropertyChanged(nameof(RatedMbps));
+ return;
+ }
+
+ _ratedMbpsByTier[tier.Display] = clamped;
+ _layoutStore.SaveRatedMbps(tier.Display, clamped);
+ ApplyStreamQuality(tier);
+ OnPropertyChanged(nameof(RatedMbps));
+ OnPropertyChanged(nameof(RateHint));
+ }
+ }
+
+ /// What YouTube expects for this tier, shown next to the field.
+ public string RateHint
+ {
+ get
+ {
+ var band = RateLadder.For(SelectedQuality);
+ return $"YouTube recommends {band.Recommended:0.#} Mbps for {SelectedQuality.Display}; " +
+ $"it will not take less than {band.Minimum:0.#}. Your connection must carry this " +
+ "plus ~20% headroom.";
+ }
+ }
+
+ /// Restores the shipped default for the selected tier.
+ public void ResetRatedMbps()
+ {
+ var tier = SelectedQuality;
+ if (!_ratedMbpsByTier.ContainsKey(tier.Display)) return;
+ _ratedMbpsByTier.Remove(tier.Display);
+ _layoutStore.SaveRatedMbps(tier.Display, null);
+ _ratedMbpsByTier[tier.Display] = RatedFor(tier);
+ ApplyStreamQuality(tier);
+ OnPropertyChanged(nameof(RatedMbps));
+ OnPropertyChanged(nameof(RateHint));
+ }
+}
diff --git a/ViewModels/MainViewModel.StepDown.cs b/ViewModels/MainViewModel.StepDown.cs
index c37a3a0..43d7179 100644
--- a/ViewModels/MainViewModel.StepDown.cs
+++ b/ViewModels/MainViewModel.StepDown.cs
@@ -23,7 +23,7 @@ public partial class MainViewModel
/// Starts the watch for this broadcast. Called once per session, before the pump starts.
private void StartStepDownWatch()
{
- var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
+ var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
_encoderTierOverride = null;
_stepDownPlanner = new StepDownPlanner(
new StepDownPolicy(quality.Width, quality.Height, quality.Fps, quality.Bitrate,
diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs
index f36878f..461211a 100644
--- a/ViewModels/MainViewModel.Streaming.Operations.cs
+++ b/ViewModels/MainViewModel.Streaming.Operations.cs
@@ -504,7 +504,7 @@ public partial class MainViewModel : ViewModelBase
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
- var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
+ var quality = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput));
// TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown
// still shows what the creator picked. Their intent is not edited by the policy.
if (_encoderTierOverride is { } landed)
diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
index 38ab1b8..9273ab9 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -488,7 +488,11 @@ public partial class MainViewModel : ViewModelBase
}
if (SetProperty(ref _selectedQuality, value))
+ {
ApplyStreamQuality(value);
+ OnPropertyChanged(nameof(RatedMbps));
+ OnPropertyChanged(nameof(RateHint));
+ }
}
}
@@ -505,7 +509,7 @@ public partial class MainViewModel : ViewModelBase
private void ApplyStreamQuality(QualityOption quality)
{
var health = CurrentHealth;
- health.CurrentBitrate = quality.Bitrate;
+ health.CurrentBitrate = EffectiveQuality(quality).Bitrate;
health.FPS = quality.Fps;
OnPropertyChanged(nameof(CurrentHealth));
diff --git a/docs/ai/ui-surface.md b/docs/ai/ui-surface.md
index f5bc984..a8e4222 100644
--- a/docs/ai/ui-surface.md
+++ b/docs/ai/ui-surface.md
@@ -144,7 +144,7 @@ Placement math itself is covered by `WindowPlacementTests` (pure function, no HW
geometry fields are 44px: the measured width of `-1920` in the app's font at 11px (via WPF's
`FormattedText` — **measure, don't guess**) plus the field's padding and border. Guard:
`ytLive.Tests/GeometryFieldWidthTests.cs`
-- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
+- Resolution tiers: 1080p60@12 (default) → 1080p30@10 → 720p60@6 → 720p30@6 → **Vertical 1080p30@10 and 1080p60@12 (9:16, 1080×1920)** — the `@` is the CBR target the encoder is asked to hold, between YouTube's H.264 recommendation and the practitioner sweet spot (its 14/17/8 recommendations are the max, the tier minimums 5/6/3 the step-down floor). Vertical mirrors its landscape pair: same pixel count, same rate. The old flat 8 Mbps was an inherited round number, not a measurement. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; vertical tiers use a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier. The resolution picker and live readouts (bitrate/FPS/dropped/duration) are in the top-left cell above the layers panel; the bottom bar is an empty height-preserving spacer. Under the picker sits the creator's own **rate box** (Mbps, per tier, persisted as `Video.Rate.`): the tier chooses geometry, the box chooses bits, clamped at the tier's YouTube minimum and otherwise unbounded because live step-down is the thing that catches a wrong guess. Its tooltip carries YouTube's recommendation for the tier (`Models/RateLadder`). Every consumer — encoder options, step-down policy, clip buffer — reads `MainViewModel.EffectiveQuality`, never the raw tier. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- **Locked output tiers stay visible and clickable.** They are dimmed for non-owners. Choosing one keeps the free tier selected and opens About in-app; only the explicit llamacasty.com link opens a browser.
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug). **The log is SIZE-CAPPED (TASK 55, 2026-10-22):** at 5 MB `startup.log` rolls to `startup.1.log` (one rolled copy kept) — the policy lives in `Helpers/LogRotation.cs` as a pure function over a path, so `AppLog`'s 156-call-site surface never changes and the rule is testable on its own. Guard: `ytLive.Tests/AppLogRotationTests.cs`
diff --git a/ytLive.Tests/RatedBitrateTests.cs b/ytLive.Tests/RatedBitrateTests.cs
new file mode 100644
index 0000000..1323a2d
--- /dev/null
+++ b/ytLive.Tests/RatedBitrateTests.cs
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using System.Linq;
+using Xunit;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The creator's own video rate: per tier, clamped at the tier's YouTube minimum, persisted across
+/// runs, and substituted into every consumer that builds encoder options.
+///
+/// The rule that must never break: whatever the creator typed is what the encoder is asked to
+/// hold, and no tier's minimum is quietly undercut.
+///
+[Collection("MainViewModelLayoutPath")]
+public class RatedBitrateTests
+{
+ [Fact]
+ public void The_creator_rate_is_per_tier_clamped_to_the_minimum_and_restored()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ var vm = new MainViewModel();
+ var tier60 = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
+ var tier30 = vm.QualityOptions.Single(tier => tier.Display == "1080p30");
+ vm.SelectedQuality = tier60;
+
+ // Unset means the shipped default, not zero.
+ Assert.Equal(tier60.Bitrate, vm.RatedMbps);
+
+ // A creator who knows their line sets their own number, and the encoder sees it.
+ vm.RatedMbps = 15;
+ Assert.Equal(15, vm.RatedMbps);
+ Assert.Equal(15, vm.EffectiveQuality(tier60).Bitrate);
+ Assert.Equal(60, vm.EffectiveQuality(tier60).Fps); // geometry stays the tier's
+
+ // Below YouTube's minimum for the tier is not a legal target.
+ vm.RatedMbps = 2;
+ Assert.Equal(RateLadder.For(tier60).Minimum, vm.RatedMbps);
+
+ // The other tier keeps its own value — one setting does not leak into the ladder.
+ vm.SelectedQuality = tier30;
+ Assert.Equal(tier30.Bitrate, vm.RatedMbps);
+ vm.SelectedQuality = tier60;
+
+ // And it survives a restart: the same profile, a fresh view model.
+ var reloaded = new MainViewModel();
+ reloaded.SelectedQuality = reloaded.QualityOptions.Single(tier => tier.Display == "1080p60");
+ Assert.Equal(RateLadder.For(tier60).Minimum, reloaded.RatedMbps);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ try { File.Delete(tempDb); } catch (IOException) { }
+ }
+ }
+}