feat(quality): the creator sets the rate — per tier, persisted, clamped only at YouTube's floor

The tier table cannot know a creator's connection, so it should not pretend to. The
Mbps box under the picker is theirs: the tier chooses geometry and frame rate, the
box chooses bits. Stored per tier (`Video.Rate.<tier>`), so tuning 1080p60 does not
touch 720p30, and none means "the shipped default" rather than zero.

Clamped at the tier's YouTube minimum (`RateLadder`), because a target under it
cannot hold the resolution; no ceiling otherwise, because a wrong guess is exactly
what live step-down exists to catch, and a creator who types 25 on a fibre line
should be allowed to find out. The tooltip carries YouTube's number for the tier.

`MainViewModel.EffectiveQuality` is the single substitution point, and the encoder
options, the step-down policy and the clip buffer all read it — so a rated tier can
never stream at the shipped default because one call site was missed.

One ordering trap found by the test: the constructor resolves the default tier
before the layout store is open, so a read at that point must not cache, or the
shipped default shadows the creator's stored rate for the whole run.

670 pass, 0 warnings.
This commit is contained in:
2026-11-12 08:11:13 -08:00
parent 93719a286d
commit f2cab5ca0f
9 changed files with 193 additions and 5 deletions
+14
View File
@@ -145,6 +145,20 @@
ItemContainerStyle="{StaticResource YtQualityComboBoxItem}"
ToolTip="{Binding ResolutionHelp}"
IsEnabled="{Binding CanChangeQuality}"/>
<!-- The creator's own rate: the tier sets the geometry, this sets the bits.
Guidance lives in the tooltip (YouTube's numbers, from RateLadder). -->
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="RatedMbpsBox" Grid.Column="0" Style="{StaticResource YtTextBoxCompact}"
Text="{Binding RatedMbps, UpdateSourceTrigger=LostFocus, StringFormat={}{0:0.#}}"
ToolTip="{Binding RateHint}"
IsEnabled="{Binding CanChangeQuality}"/>
<TextBlock Grid.Column="1" Text="Mbps" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{StaticResource Brush.OnSurfaceVariant}" FontSize="12"/>
</Grid>
</StackPanel>
</Grid>
</Border>
+20
View File
@@ -27,6 +27,26 @@ public partial class LayoutStore : IDisposable
public void SaveStickyAlertsUntilDismissed(bool enabled)
=> UpsertSetting("Notifications.StickyUntilDismissed", enabled ? "1" : "0");
/// <summary>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).</summary>
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
+1 -1
View File
@@ -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)
{
+89
View File
@@ -0,0 +1,89 @@
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// 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.
///
/// <para><b>Why per tier:</b> 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 <see cref="EffectiveQuality"/> substitutes.</para>
///
/// <para><b>Why this matters now:</b> with CBR holding the target (<c>FfmpegArgs.AddRateControl</c>)
/// the number the creator types is the number that goes out, so it is a real quality choice rather
/// than an average to undershoot.</para>
/// </summary>
public partial class MainViewModel
{
private readonly Dictionary<string, double> _ratedMbpsByTier = new(StringComparer.Ordinal);
/// <summary>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.</summary>
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;
}
/// <summary>The rate the selected tier will stream at, in Mbps.</summary>
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));
}
}
/// <summary>What YouTube expects for this tier, shown next to the field.</summary>
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.";
}
}
/// <summary>Restores the shipped default for the selected tier.</summary>
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));
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ public partial class MainViewModel
/// <summary>Starts the watch for this broadcast. Called once per session, before the pump starts.</summary>
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,
@@ -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)
+5 -1
View File
@@ -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));
+1 -1
View File
@@ -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.<tier>`): 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`
+61
View File
@@ -0,0 +1,61 @@
using System;
using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
[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) { }
}
}
}